diff --git a/internal/api/src/routes/files.server.ts b/internal/api/src/routes/files.server.ts
index edb6a8b8..640144f0 100644
--- a/internal/api/src/routes/files.server.ts
+++ b/internal/api/src/routes/files.server.ts
@@ -49,8 +49,10 @@ export default function mountFiles(server: APIServer) {
// Note: this happens with createServer from node:http, not with Bun.serve.
return c.body(file.stream, 200, {
"Content-Type": file.type,
- // Inline to prevent the browser from downloading the file.
"Content-Disposition": `inline; filename="${file.name}"`,
+ "X-Content-Type-Options": "nosniff",
+ "Content-Security-Policy":
+ "default-src 'none'; sandbox; frame-ancestors 'none'",
});
});
}
diff --git a/internal/api/src/routes/files.test.ts b/internal/api/src/routes/files.test.ts
index 11eff827..5280524d 100644
--- a/internal/api/src/routes/files.test.ts
+++ b/internal/api/src/routes/files.test.ts
@@ -3,7 +3,7 @@ import { serve } from "../test";
test("POST+GET /api/files", async () => {
const { helpers } = await serve();
- const { client, user } = await helpers.createUser();
+ const { client } = await helpers.createUser();
const file = new File(["Hello, world!"], "test.txt");
const resp = await client.files.upload(file);
expect(resp.id).toBeString();
@@ -12,3 +12,27 @@ test("POST+GET /api/files", async () => {
const fileResp = await client.files.get(resp.id);
expect(await fileResp.text()).toBe("Hello, world!");
});
+
+test("GET /api/files serves uploaded files inline with restrictive headers", async () => {
+ const { helpers } = await serve();
+ const { client } = await helpers.createUser();
+ const file = new File(["
content
"], "content.html", {
+ type: "text/html",
+ });
+
+ const uploaded = await client.files.upload(file);
+ const response = await fetch(uploaded.url);
+
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe("content
");
+ expect(response.headers.get("content-type")).toStartWith("text/html");
+ expect(response.headers.get("content-disposition")).toBe(
+ 'inline; filename="content.html"'
+ );
+ expect(response.headers.get("x-content-type-options")).toBe("nosniff");
+ const csp = response.headers.get("content-security-policy");
+ expect(csp).toContain("default-src 'none'");
+ expect(csp).toContain("sandbox");
+ expect(csp).toContain("frame-ancestors 'none'");
+ expect(response.headers.get("x-frame-options")).toBeNull();
+});
diff --git a/internal/database/src/postgres-worker.ts b/internal/database/src/postgres-worker.ts
index 5542fdbe..ecf1f8e5 100644
--- a/internal/database/src/postgres-worker.ts
+++ b/internal/database/src/postgres-worker.ts
@@ -506,6 +506,7 @@ self.onmessage = async (e) => {
},
async onMessage(data, { isAuthenticated }) {
if (!isAuthenticated) return;
+ if (data[0] === FrontendMessageCode.Terminate) return;
maybeClaimOnEnqueue(socket, data);
return new Promise((resolve, reject) => {
taskQueue.push({ socket, data, resolve, reject });
@@ -519,9 +520,12 @@ self.onmessage = async (e) => {
const cleanupSocket = () => {
releaseOwnerIf(socket);
for (let i = taskQueue.length - 1; i >= 0; i--) {
- if (taskQueue[i]!.socket === socket) {
+ const task = taskQueue[i];
+ if (task?.socket === socket) {
try {
- taskQueue[i]!.reject(new Error("Socket closed"));
+ // Closed sockets cannot receive pending responses, so complete
+ // queued work without producing an error.
+ task.resolve(new Uint8Array(0));
} catch {}
taskQueue.splice(i, 1);
}
diff --git a/internal/site/components/chat-message-input.test.tsx b/internal/site/components/chat-message-input.test.tsx
index 8d48d97f..87c75446 100644
--- a/internal/site/components/chat-message-input.test.tsx
+++ b/internal/site/components/chat-message-input.test.tsx
@@ -1,5 +1,7 @@
-import { render, waitFor } from "@testing-library/react";
+// Load the core package first so Bun initializes Lexical's shared exports before @lexical/react.
+import "lexical";
import { afterAll, beforeAll, expect, test } from "bun:test";
+import { render, waitFor } from "@testing-library/react";
import { Window } from "happy-dom";
import {
ChatMessageInput,
@@ -14,13 +16,13 @@ beforeAll(() => {
});
afterAll(async () => {
- // @ts-ignore
+ // @ts-expect-error
delete globalThis.window;
- // @ts-ignore
+ // @ts-expect-error
delete globalThis.document;
- // @ts-ignore
+ // @ts-expect-error
delete globalThis.MutationObserver;
- // @ts-ignore
+ // @ts-expect-error
delete globalThis.getComputedStyle;
});
diff --git a/internal/site/components/chat-multimodal-input.test.tsx b/internal/site/components/chat-multimodal-input.test.tsx
index eca8381f..a112562a 100644
--- a/internal/site/components/chat-multimodal-input.test.tsx
+++ b/internal/site/components/chat-multimodal-input.test.tsx
@@ -1,7 +1,9 @@
-import type { UIAttachment } from "@/hooks/use-attachments";
-import { fireEvent, render, waitFor } from "@testing-library/react";
+// Load the core package first so Bun initializes Lexical's shared exports before @lexical/react.
+import "lexical";
import { afterAll, beforeAll, expect, mock, test } from "bun:test";
+import { fireEvent, render, waitFor } from "@testing-library/react";
import { Window } from "happy-dom";
+import type { UIAttachment } from "@/hooks/use-attachments";
import type { ChatMessageInputRef } from "./chat-message-input";
import {
ChatMultimodalInput,
@@ -17,13 +19,13 @@ beforeAll(() => {
});
afterAll(async () => {
- // @ts-ignore
+ // @ts-expect-error
delete globalThis.window;
- // @ts-ignore
+ // @ts-expect-error
delete globalThis.document;
- // @ts-ignore
+ // @ts-expect-error
delete globalThis.MutationObserver;
- // @ts-ignore
+ // @ts-expect-error
delete globalThis.getComputedStyle;
});