fix: serve WOPI file downloads as inert attachments

GetFile answered on the webmail origin with the Content-Type stored in
the WOPI token, which for a mail attachment comes straight from the
launch request and for a Files node is a user-settable property. No
CSP, nosniff or disposition was added, since `/api/*` skips the proxy.
A user could mint a token for their own blob typed `text/html` and have
a victim open the GetFile URL, running script in the webmail origin.

GetFile now always sends `application/octet-stream` with
`Content-Disposition: attachment`, `nosniff` and a sandboxing CSP, and
refuses browser loads (any `Sec-Fetch-Dest` other than `empty`). The
editor fetches it server-to-server, so it is unaffected.
This commit is contained in:
Linus Rath
2026-09-26 14:16:42 +02:00
parent 012fe6edad
commit dd796d7325
2 changed files with 44 additions and 5 deletions
+23 -2
View File
@@ -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<string, string> = {
'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 });
+21 -3
View File
@@ -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<string, unknown> = {}) {
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(