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:
co-authored by
Claude Opus 5
parent
6520cffc95
commit
f7691fd1bb
@@ -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('..')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user