feat(13-06): content adapter — ContentItem to Film/Song/Podcast, close the streamUrl JWT leak

- archyContentAdapter.ts: hand-written adaptContentItems mapping (D-12),
  fixture-pinned at the adjacency, empty, ordering and paid-lock edges
  named in AIUI-03; classifyByMime covers the m4a/aac/opus/wma extension
  gap ShareModal.vue's mime map leaves today; buildMediaUrl never puts a
  credential in a query string (T-13-32).
- filebrowser-client.ts: streamUrl now returns a query-free same-origin
  raw-file URL, relying on the path=/ cookie login() already sets instead
  of also putting the JWT in the URL (T-13-39 — closes the pre-existing
  leak CONTEXT.md names, rather than merely not repeating it).
- filebrowserStreamUrl.test.ts: regression pin for the fix, including a
  traversal case confirming sanitizePath behavior is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-03 19:27:09 -04:00
co-authored by Claude Opus 5
parent 6520cffc95
commit f7691fd1bb
4 changed files with 806 additions and 5 deletions
@@ -0,0 +1,98 @@
/**
* Regression pin for T-13-39 — `streamUrl` used to append `?auth=<jwt>` to
* the raw-file URL, leaking the filebrowser JWT into browser history,
* `Referer` headers and access logs. 13-CONTEXT.md names this "the known
* leak to fix rather than propagate"; this file pins the fix so it cannot
* silently regress.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// FileBrowserClient reads window.location.origin in its constructor.
Object.defineProperty(window, 'location', {
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost', pathname: '/app/filebrowser' },
writable: true,
})
const { fileBrowserClient } = await import('../filebrowser-client')
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
headers: new Headers({ 'content-type': 'application/json' }),
redirected: false,
type: 'basic' as ResponseType,
url: '',
clone: () => jsonResponse(body, status),
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
formData: () => Promise.resolve(new FormData()),
bytes: () => Promise.resolve(new Uint8Array()),
}
}
describe('FileBrowserClient.streamUrl', () => {
beforeEach(() => {
mockFetch.mockReset()
;(fileBrowserClient as unknown as { _authenticated: boolean })._authenticated = false
document.cookie = 'auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
})
it('resolves to a same-origin raw-file URL with no query component', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'super-secret-jwt-token' } }))
const url = await fileBrowserClient.streamUrl('/Music/song.m4a')
expect(url).toBe('http://localhost/app/filebrowser/api/raw/Music/song.m4a')
expect(url).not.toContain('?')
})
it('never embeds the filebrowser JWT anywhere in the returned string', async () => {
const token = 'super-secret-jwt-token-value-12345'
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token } }))
const url = await fileBrowserClient.streamUrl('/Videos/movie.mp4')
expect(url).not.toContain(token)
expect(url).not.toMatch(/[?&]auth=/)
})
it('awaits authentication (sets the cookie the media request relies on) before returning', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'jwt-abc' } }))
await fileBrowserClient.streamUrl('/Videos/movie.mp4')
// The cookie login() sets is what the same-origin media request depends
// on now that the URL itself carries no credential — assert it's really
// there by the time the caller has the URL in hand.
expect(document.cookie).toContain('auth=jwt-abc')
})
it('does not re-authenticate when a valid session already exists', async () => {
;(fileBrowserClient as unknown as { _authenticated: boolean })._authenticated = true
document.cookie = 'auth=already-authed'
const url = await fileBrowserClient.streamUrl('/a.mp3')
expect(mockFetch).not.toHaveBeenCalled()
expect(url).toBe('http://localhost/app/filebrowser/api/raw/a.mp3')
})
it('still resolves traversal via sanitizePath — a path cannot escape root', async () => {
;(fileBrowserClient as unknown as { _authenticated: boolean })._authenticated = true
document.cookie = 'auth=already-authed'
const url = await fileBrowserClient.streamUrl('/Music/../../etc/passwd')
expect(url).toBe('http://localhost/app/filebrowser/api/raw/etc/passwd')
expect(url).not.toContain('..')
})
})
+15 -5
View File
@@ -165,15 +165,25 @@ class FileBrowserClient {
}
/**
* Get a direct streaming URL with auth token in query string.
* Use for video/audio <src> where browser needs to stream (range requests).
* The token is a short-lived JWT so exposure in URL is acceptable.
* Get a direct streaming URL for video/audio `<src>` where the browser
* needs to make Range requests.
*
* Carries NO credential in the query string (T-13-39, fixed 2026-08-03 —
* this was "the known leak to fix rather than propagate", per
* 13-CONTEXT.md). `login()` already sets the filebrowser JWT as a
* `path=/` cookie on this page's own origin, `baseUrl` is that same
* origin, and the browser attaches the cookie to the same-origin media
* subresource request automatically — the same mechanism filebrowser's
* own web UI relies on. Putting the token in the URL too was redundant,
* and it reached browser history, `Referer` headers and any access log on
* the path. The cookie itself is unchanged by this fix: it is still a
* 24-hour JWT, now confined to the cookie jar rather than also appearing
* in the URL.
*/
async streamUrl(path: string): Promise<string> {
await this.ensureAuth()
const token = this.getAuthCookie()
const safePath = sanitizePath(path)
return `${this.baseUrl}/api/raw${safePath}?auth=${token}`
return `${this.baseUrl}/api/raw${safePath}`
}
/**