Allow notification thumbnails from admitted LAN app origins

This commit is contained in:
archipelago
2026-10-07 18:41:06 -04:00
parent 80ebf75313
commit 0c1d1b5796
7 changed files with 105 additions and 15 deletions
@@ -7,7 +7,7 @@ import GlobalAudioPlayer from '../GlobalAudioPlayer.vue'
describe('native app player controls', () => {
it('renders app artwork and synchronized controls without a second audio source', async () => {
fixture.player = {
currentName: ref('Song · Artist'), externalVisible: ref(false), externalApp: ref(true), externalArtwork: ref('https://node.test:7475/media/cover.jpg'), externalShuffle: ref(true),
currentName: ref('Song · Artist'), externalVisible: ref(false), externalApp: ref(true), externalArtwork: ref('https://node.test:7475/media/cover.jpg'), externalArtworkOrigin: ref('https://node.test:7475'), externalShuffle: ref(true),
playing: ref(true), loading: ref(false), error: ref(null), currentTime: ref(4), duration: ref(90), progress: ref(5), currentSrc: ref('app:session'),
previousExternal: vi.fn(), nextExternal: vi.fn(), shuffleExternal: vi.fn(), openExternal: vi.fn(), pause: vi.fn(), play: vi.fn(), stop: vi.fn(), seek: vi.fn(),
}
@@ -49,7 +49,7 @@ describe('app media session boundary', () => {
bridge.connect()
const hello = child.postMessage.mock.calls[0]![0]
expect(child.postMessage.mock.calls[0]![1]).toBe('https://node.test:7475')
const state = { type: 'archipelago:media-state', version: 1, session: hello.session, available: true, title: 'Song', artist: 'Artist', playing: true, position: 22, duration: 100, artwork: '/media/cover.jpg', shuffle: true }
const state = { type: 'archipelago:media-state', version: 1, session: hello.session, available: true, title: 'Song', artist: 'Artist', playing: true, position: 22, duration: 100, artwork: '/media/cover.jpg', artworkOrigin: 'http://untrusted.test', shuffle: true }
const message = (data = state, origin = 'https://node.test:7475', source: unknown = child) => bridge.handle({ data, origin, source } as MessageEvent)
message(state, 'http://node.test:7475'); message(state, 'https://evil.test'); message(state, 'https://node.test:7475', {})
message({ ...state, session: 'wrong' }); message({ ...state, duration: NaN })
@@ -57,6 +57,7 @@ describe('app media session boundary', () => {
message()
expect(fixture.player.updateExternal).toHaveBeenCalledOnce()
const [controller, snapshot, shown] = fixture.player.updateExternal.mock.calls[0]!
expect(controller.artworkOrigin).toBe('https://node.test:7475');
expect(snapshot.artwork).toBe('https://node.test:7475/media/cover.jpg'); expect(snapshot.shuffle).toBe(true);
controller.next(); controller.previous(); controller.shuffle();
expect(child.postMessage.mock.calls.slice(-3).map(call => call[0].command)).toEqual(['next', 'previous', 'shuffle']);
@@ -1,18 +1,18 @@
import { mount } from '@vue/test-utils'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useCompanionAudio } from '../useCompanionAudio'
import type { useAudioPlayer } from '../useAudioPlayer'
beforeEach(() => vi.useFakeTimers())
afterEach(() => { vi.useRealTimers(); delete (window as any).ArchipelagoAudio })
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); delete (window as any).ArchipelagoAudio })
function fixture(install = true) {
const messages: any[] = []
const bridge: any = { onmessage: null, postMessage: (raw: string) => messages.push(JSON.parse(raw)) }
if (install) (window as any).ArchipelagoAudio = bridge
const player = {
currentSrc: ref<string | null>('https://node.test/private?token=secret'), currentName: ref('A song'),
playing: ref(true), currentTime: ref(12), duration: ref(120), externalArtwork: ref(''),
playing: ref(true), currentTime: ref(12), duration: ref(120), externalArtwork: ref(''), externalArtworkOrigin: ref(''),
externalApp: ref(true), externalShuffle: ref(false), error: ref<string | null>(null),
pause: vi.fn(), play: vi.fn(), seek: vi.fn(), nextExternal: vi.fn(), previousExternal: vi.fn(), shuffleExternal: vi.fn(),
stop: vi.fn(() => { player.currentSrc.value = null; player.playing.value = false }),
@@ -81,6 +81,44 @@ describe('companion native audio control of the existing player', () => {
f.wrapper.unmount(); const count = f.messages.length
await vi.advanceTimersByTimeAsync(30000); expect(f.messages).toHaveLength(count)
})
it('decodes artwork from the admitted HTTP app port without forwarding cookies or following redirects', async () => {
const jpeg = 'data:image/jpeg;base64,/9j/fixture';
const fetchImage = vi.fn(async () => ({ ok: true, headers: new Headers({ 'content-type': 'image/jpeg' }),
body: new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array([255, 216, 255])); controller.close() } }) }))
vi.stubGlobal('fetch', fetchImage)
vi.stubGlobal('createImageBitmap', vi.fn(async () => ({ close: vi.fn() })))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage: vi.fn() } as any)
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(jpeg)
const f = fixture(); await nextTick()
f.player.externalArtworkOrigin.value = 'http://node.test:7475'
f.player.externalArtwork.value = 'http://node.test:7475/cover.jpg'
await nextTick(); await flushPromises()
expect(fetchImage).toHaveBeenCalledWith('http://node.test:7475/cover.jpg', expect.objectContaining({ credentials: 'same-origin', redirect: 'error', referrerPolicy: 'no-referrer' }))
expect(f.messages.some(value => value.artwork === jpeg)).toBe(true)
expect(JSON.stringify(f.messages)).not.toContain('/cover.jpg')
f.wrapper.unmount()
})
it('rejects arbitrary HTTP artwork and credentials even with an admitted app origin', async () => {
const fetchImage = vi.fn(); vi.stubGlobal('fetch', fetchImage)
const f = fixture(); await nextTick()
f.player.externalArtworkOrigin.value = 'http://node.test:7475'
for (const url of ['http://other.test/image.jpg', 'http://node.test:7476/image.jpg', 'http://user:password@node.test:7475/image.jpg']) {
f.player.externalArtwork.value = url; await nextTick(); await flushPromises()
}
expect(fetchImage).not.toHaveBeenCalled()
expect(f.player.pause).not.toHaveBeenCalled()
f.wrapper.unmount()
})
it('treats a denied redirect or image-fetch failure as optional without pausing music', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('redirect denied')))
const f = fixture(); await nextTick()
f.player.externalArtworkOrigin.value = 'http://node.test:7475'
f.player.externalArtwork.value = 'http://node.test:7475/redirect'
await nextTick(); await flushPromises()
expect(f.player.pause).not.toHaveBeenCalled()
expect(f.messages[f.messages.length - 1]).toMatchObject({ playing: true })
f.wrapper.unmount()
})
it('does not revive paused sessions and reports native start failures without leaking details', async () => {
const f = fixture(); await nextTick()
f.bridge.onmessage({ data: JSON.stringify({ version: 1, session: f.messages[0].session, type: 'error', error: 'sensitive details' }) })
@@ -43,7 +43,7 @@ export function useAppMediaBridge(appId: Ref<string>, url: Ref<string>, frame: R
}
admitted.source.postMessage({ type: 'archipelago:media-connect', version: 1, session }, admitted.origin)
}
const controller = { id, play: () => command('play'), pause: () => command('pause'), seek: (position: number) => command('seek', position),
const controller = { id, get artworkOrigin() { return admitted?.origin || '' }, play: () => command('play'), pause: () => command('pause'), seek: (position: number) => command('seek', position),
next: () => command('next'), previous: () => command('previous'), shuffle: () => command('shuffle'),
open: () => launcher.openSession(appId.value), close: () => {
if (launcher.mediaAppId === appId.value && launcher.panelAppId !== appId.value) launcher.mediaAppId = null
+6 -1
View File
@@ -13,6 +13,8 @@ let initialized = false
export interface ExternalAudioState { title: string; artist?: string; playing: boolean; position: number; duration: number; artwork?: string; shuffle?: boolean }
export interface ExternalAudioController {
id: string
/** Origin admitted by the host media bridge, never taken from a media-state payload. */
artworkOrigin?: string
play: () => void
pause: () => void
seek: (position: number) => void
@@ -27,6 +29,7 @@ const suppressedExternal = new Set<string>()
const externalVisible = ref(false)
const externalApp = ref(false)
const externalArtwork = ref('')
const externalArtworkOrigin = ref('')
const externalShuffle = ref(false)
function nextExternal() { external?.next?.() }
function previousExternal() { external?.previous?.() }
@@ -41,6 +44,7 @@ function updateExternal(controller: ExternalAudioController, state: ExternalAudi
external = controller
}
externalArtwork.value = state.artwork || ''
externalArtworkOrigin.value = controller.artworkOrigin || ''
externalShuffle.value = state.shuffle === true
externalApp.value = true
externalVisible.value = visible
@@ -56,6 +60,7 @@ function detachExternal(id: string) {
if (external?.id !== id) return
external = null
externalArtwork.value = ''
externalArtworkOrigin.value = ''
externalShuffle.value = false
externalApp.value = false
externalVisible.value = false
@@ -181,7 +186,7 @@ const progress = computed(() => {
export function useAudioPlayer() {
return {
externalArtwork, externalShuffle, nextExternal, previousExternal, shuffleExternal,
externalArtwork, externalArtworkOrigin, externalShuffle, nextExternal, previousExternal, shuffleExternal,
updateExternal, detachExternal, releaseExternal, setExternalVisible, openExternal, externalVisible, externalApp,
play,
pause,
@@ -14,11 +14,14 @@ function audioSessionId(): string {
}
/** Send a small decoded thumbnail, never a protected URL or authorization data. */
async function thumbnail(url: string, signal: AbortSignal): Promise<string> {
async function thumbnail(url: string, admittedOrigin: string, signal: AbortSignal): Promise<string> {
if (!url) return ''
const source = new URL(url, window.location.href)
if (source.username || source.password || (source.protocol !== 'https:' && source.origin !== window.location.origin)) return ''
const response = await fetch(source.href, { signal, credentials: 'same-origin', referrerPolicy: 'no-referrer' })
// An app's authenticated media origin can use a different port on LAN HTTP.
// Never authorize an arbitrary HTTP origin from the artwork payload itself.
if (source.username || source.password || !(source.protocol === 'https:'
|| (source.protocol === 'http:' && (source.origin === window.location.origin || source.origin === admittedOrigin)))) return ''
const response = await fetch(source.href, { signal, credentials: 'same-origin', referrerPolicy: 'no-referrer', redirect: 'error' })
if (!response.ok || !response.headers.get('content-type')?.startsWith('image/')) return ''
const reader = response.body?.getReader()
if (!reader) return ''
@@ -113,7 +116,7 @@ export function useCompanionAudio(player = useAudioPlayer()) {
imageRequest?.abort(); artwork = ''
if (!session || !player.externalArtwork.value) return
const target = session; const request = new AbortController(); imageRequest = request
void thumbnail(player.externalArtwork.value, request.signal).then(value => {
void thumbnail(player.externalArtwork.value, player.externalArtworkOrigin.value, request.signal).then(value => {
if (!disposed && !request.signal.aborted && target === session) { artwork = value; publish() }
}).catch(() => { /* Optional image failure never interrupts music. */ })
}
@@ -122,7 +125,7 @@ export function useCompanionAudio(player = useAudioPlayer()) {
watch([player.currentTime, player.duration], () => {
if (!timer) timer = setTimeout(() => { timer = undefined; publish() }, 1000)
})
watch(player.externalArtwork, () => { loadArtwork(); publish() })
watch([player.externalArtwork, player.externalArtworkOrigin], () => { loadArtwork(); publish() })
const sync = () => publish()
window.addEventListener('pageshow', sync)
document.addEventListener('visibilitychange', sync)