mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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 });
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user