diff --git a/app/api/wopi/files/[fileId]/contents/route.ts b/app/api/wopi/files/[fileId]/contents/route.ts index 99e0b65ee..db8524005 100644 --- a/app/api/wopi/files/[fileId]/contents/route.ts +++ b/app/api/wopi/files/[fileId]/contents/route.ts @@ -10,11 +10,33 @@ import { wopiContext } from '@/lib/wopi/request'; * points the FileNode at it via `FileNode/set { blobId }`. */ +/** + * GetFile answers on the webmail origin, and the type of the bytes comes + * from the launch request or a user-settable FileNode property. Served + * inline as `text/html` or `image/svg+xml` it would run script as the + * webmail, so the response is always an inert download whatever the + * source claims to be. + */ +const GET_FILE_HEADERS: Record = { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': 'attachment', + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'; sandbox", + 'Cache-Control': 'no-store', +}; + /** GET = GetFile */ export async function GET( request: NextRequest, { params }: { params: Promise<{ fileId: string }> }, ) { + // The editor fetches this server-to-server and sends no Sec-Fetch headers. + // A browser navigating to it, or loading it as a script or style, is not + // the editor. + const dest = request.headers.get('sec-fetch-dest'); + if (dest !== null && dest !== 'empty') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } try { const { fileId: documentId } = await params; const auth = await wopiContext(request, documentId); @@ -41,8 +63,7 @@ export async function GET( return NextResponse.json({ error: 'Blob download failed' }, { status: 502 }); } - const headers = new Headers(); - headers.set('Content-Type', source.type || 'application/octet-stream'); + const headers = new Headers(GET_FILE_HEADERS); const contentLength = upstream.headers.get('Content-Length'); if (contentLength) headers.set('Content-Length', contentLength); return new NextResponse(upstream.body, { status: 200, headers }); diff --git a/lib/__tests__/wopi-attachment-routes.test.ts b/lib/__tests__/wopi-attachment-routes.test.ts index dde76feec..4fb17646a 100644 --- a/lib/__tests__/wopi-attachment-routes.test.ts +++ b/lib/__tests__/wopi-attachment-routes.test.ts @@ -126,8 +126,8 @@ describe('WOPI attachment launch', () => { }); describe('WOPI calls for an attachment token', () => { - async function launched() { - const { data } = await launchAttachment({ blobId: 'Gblob1', name: 'Quote.docx', type: DOCX, size: 999 }); + async function launched(overrides: Record = {}) { + const { data } = await launchAttachment({ blobId: 'Gblob1', name: 'Quote.docx', type: DOCX, size: 999, ...overrides }); (fetchJmapServer as Mock).mockClear(); return editorCall(data); } @@ -152,11 +152,29 @@ describe('WOPI calls for an attachment token', () => { const { documentId, token } = await launched(); const res = await getFile(request(`/api/wopi/files/${documentId}/contents?${token}`), params(documentId)); expect(res.status).toBe(200); - expect(res.headers.get('Content-Type')).toBe(DOCX); expect(await res.text()).toBe('DOCX-BYTES'); expect((fetchJmapServer as Mock).mock.calls[0][0]).toContain('/jmap/download/c/Gblob1/Quote.docx'); }); + it('GetFile serves the bytes as an inert download whatever type the launch claimed', async () => { + const { documentId, token } = await launched({ type: 'text/html' }); + const res = await getFile(request(`/api/wopi/files/${documentId}/contents?${token}`), params(documentId)); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('application/octet-stream'); + expect(res.headers.get('Content-Disposition')).toBe('attachment'); + expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff'); + expect(res.headers.get('Content-Security-Policy')).toBe("default-src 'none'; sandbox"); + }); + + it.each(['document', 'iframe', 'script'])('GetFile refuses a browser load as %s', async (dest) => { + const { documentId, token } = await launched(); + const res = await getFile( + request(`/api/wopi/files/${documentId}/contents?${token}`, { headers: { 'sec-fetch-dest': dest } }), + params(documentId), + ); + expect(res.status).toBe(403); + }); + it('PutFile is refused', async () => { const { documentId, token } = await launched(); const res = await putFile(