Skip to content

Commit 40b21f6

Browse files
fix(internal): harden file response handling (#302)
1 parent 17ae2ed commit 40b21f6

5 files changed

Lines changed: 49 additions & 15 deletions

File tree

internal/api/src/routes/files.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,10 @@ export default function mountFiles(server: APIServer) {
4949
// Note: this happens with createServer from node:http, not with Bun.serve.
5050
return c.body(file.stream, 200, {
5151
"Content-Type": file.type,
52-
// Inline to prevent the browser from downloading the file.
5352
"Content-Disposition": `inline; filename="${file.name}"`,
53+
"X-Content-Type-Options": "nosniff",
54+
"Content-Security-Policy":
55+
"default-src 'none'; sandbox; frame-ancestors 'none'",
5456
});
5557
});
5658
}

internal/api/src/routes/files.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { serve } from "../test";
33

44
test("POST+GET /api/files", async () => {
55
const { helpers } = await serve();
6-
const { client, user } = await helpers.createUser();
6+
const { client } = await helpers.createUser();
77
const file = new File(["Hello, world!"], "test.txt");
88
const resp = await client.files.upload(file);
99
expect(resp.id).toBeString();
@@ -12,3 +12,27 @@ test("POST+GET /api/files", async () => {
1212
const fileResp = await client.files.get(resp.id);
1313
expect(await fileResp.text()).toBe("Hello, world!");
1414
});
15+
16+
test("GET /api/files serves uploaded files inline with restrictive headers", async () => {
17+
const { helpers } = await serve();
18+
const { client } = await helpers.createUser();
19+
const file = new File(["<h1>content</h1>"], "content.html", {
20+
type: "text/html",
21+
});
22+
23+
const uploaded = await client.files.upload(file);
24+
const response = await fetch(uploaded.url);
25+
26+
expect(response.status).toBe(200);
27+
expect(await response.text()).toBe("<h1>content</h1>");
28+
expect(response.headers.get("content-type")).toStartWith("text/html");
29+
expect(response.headers.get("content-disposition")).toBe(
30+
'inline; filename="content.html"'
31+
);
32+
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
33+
const csp = response.headers.get("content-security-policy");
34+
expect(csp).toContain("default-src 'none'");
35+
expect(csp).toContain("sandbox");
36+
expect(csp).toContain("frame-ancestors 'none'");
37+
expect(response.headers.get("x-frame-options")).toBeNull();
38+
});

internal/database/src/postgres-worker.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,7 @@ self.onmessage = async (e) => {
506506
},
507507
async onMessage(data, { isAuthenticated }) {
508508
if (!isAuthenticated) return;
509+
if (data[0] === FrontendMessageCode.Terminate) return;
509510
maybeClaimOnEnqueue(socket, data);
510511
return new Promise<Uint8Array>((resolve, reject) => {
511512
taskQueue.push({ socket, data, resolve, reject });
@@ -519,9 +520,12 @@ self.onmessage = async (e) => {
519520
const cleanupSocket = () => {
520521
releaseOwnerIf(socket);
521522
for (let i = taskQueue.length - 1; i >= 0; i--) {
522-
if (taskQueue[i]!.socket === socket) {
523+
const task = taskQueue[i];
524+
if (task?.socket === socket) {
523525
try {
524-
taskQueue[i]!.reject(new Error("Socket closed"));
526+
// Closed sockets cannot receive pending responses, so complete
527+
// queued work without producing an error.
528+
task.resolve(new Uint8Array(0));
525529
} catch {}
526530
taskQueue.splice(i, 1);
527531
}

internal/site/components/chat-message-input.test.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { render, waitFor } from "@testing-library/react";
1+
// Load the core package first so Bun initializes Lexical's shared exports before @lexical/react.
2+
import "lexical";
23
import { afterAll, beforeAll, expect, test } from "bun:test";
4+
import { render, waitFor } from "@testing-library/react";
35
import { Window } from "happy-dom";
46
import {
57
ChatMessageInput,
@@ -14,13 +16,13 @@ beforeAll(() => {
1416
});
1517

1618
afterAll(async () => {
17-
// @ts-ignore
19+
// @ts-expect-error
1820
delete globalThis.window;
19-
// @ts-ignore
21+
// @ts-expect-error
2022
delete globalThis.document;
21-
// @ts-ignore
23+
// @ts-expect-error
2224
delete globalThis.MutationObserver;
23-
// @ts-ignore
25+
// @ts-expect-error
2426
delete globalThis.getComputedStyle;
2527
});
2628

internal/site/components/chat-multimodal-input.test.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import type { UIAttachment } from "@/hooks/use-attachments";
2-
import { fireEvent, render, waitFor } from "@testing-library/react";
1+
// Load the core package first so Bun initializes Lexical's shared exports before @lexical/react.
2+
import "lexical";
33
import { afterAll, beforeAll, expect, mock, test } from "bun:test";
4+
import { fireEvent, render, waitFor } from "@testing-library/react";
45
import { Window } from "happy-dom";
6+
import type { UIAttachment } from "@/hooks/use-attachments";
57
import type { ChatMessageInputRef } from "./chat-message-input";
68
import {
79
ChatMultimodalInput,
@@ -17,13 +19,13 @@ beforeAll(() => {
1719
});
1820

1921
afterAll(async () => {
20-
// @ts-ignore
22+
// @ts-expect-error
2123
delete globalThis.window;
22-
// @ts-ignore
24+
// @ts-expect-error
2325
delete globalThis.document;
24-
// @ts-ignore
26+
// @ts-expect-error
2527
delete globalThis.MutationObserver;
26-
// @ts-ignore
28+
// @ts-expect-error
2729
delete globalThis.getComputedStyle;
2830
});
2931

0 commit comments

Comments
 (0)