authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 19:58:33-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-27 21:30:46-08:00
logb5188e8ceaee3940d2fb3cd88c8fc2121fef1ae7
tree7b6e3246022b3f1b868bb10ec869c317551928ec
parent992db1658633ff268be5d1167634966ae63ed859

Harden attachment materialization and static file path handling

- Materialize only valid image data URLs and restrict inferred file extensions to a safe whitelist - Preserve existing message attachments on upsert unless explicitly replaced or cleared - Serve static `/` via `index.html` and block traversal attempts in static file requests

7 files changed, 364 insertions(+), 15 deletions(-)

apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+111
...@@ -247,6 +247,117 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -247,6 +247,117 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
247 }),247 }),
248 );248 );
249249
250 it.effect("materializes only image data URLs and uses a safe extension whitelist", () =>
251 Effect.gen(function* () {
252 const projectionPipeline = yield* OrchestrationProjectionPipeline;
253 const eventStore = yield* OrchestrationEventStore;
254 const sql = yield* SqlClient.SqlClient;
255 const now = new Date().toISOString();
256 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"));
257
258 const serverConfig = {
259 mode: "web",
260 port: 0,
261 host: undefined,
262 cwd: "/tmp/project-attachments",
263 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
264 stateDir,
265 staticDir: undefined,
266 devUrl: undefined,
267 noBrowser: true,
268 authToken: undefined,
269 autoBootstrapProjectFromCwd: false,
270 logWebSocketEvents: false,
271 } satisfies ServerConfigShape;
272
273 yield* eventStore.append({
274 type: "thread.message-sent",
275 eventId: EventId.makeUnsafe("evt-attachments-safe"),
276 aggregateKind: "thread",
277 aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"),
278 occurredAt: now,
279 commandId: CommandId.makeUnsafe("cmd-attachments-safe"),
280 causationEventId: null,
281 correlationId: CommandId.makeUnsafe("cmd-attachments-safe"),
282 metadata: {},
283 payload: {
284 threadId: ThreadId.makeUnsafe("thread-attachments-safe"),
285 messageId: MessageId.makeUnsafe("message-attachments-safe"),
286 role: "user",
287 text: "Inspect this",
288 attachments: [
289 {
290 type: "image",
291 name: "untrusted.exe",
292 mimeType: "image/x-unknown",
293 sizeBytes: 5,
294 dataUrl: "data:image/x-unknown;base64,SGVsbG8=",
295 },
296 {
297 type: "image",
298 name: "not-image.png",
299 mimeType: "image/png",
300 sizeBytes: 5,
301 dataUrl: "data:text/plain;base64,SGVsbG8=",
302 },
303 ],
304 turnId: null,
305 streaming: false,
306 createdAt: now,
307 updatedAt: now,
308 },
309 });
310
311 yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig));
312
313 const rows = yield* sql<{
314 readonly attachmentsJson: string | null;
315 }>`
316 SELECT
317 attachments_json AS "attachmentsJson"
318 FROM projection_thread_messages
319 WHERE message_id = 'message-attachments-safe'
320 `;
321 assert.equal(rows.length, 1);
322 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
323 {
324 type: "image",
325 name: "untrusted.exe",
326 mimeType: "image/x-unknown",
327 sizeBytes: 5,
328 dataUrl: "/attachments/thread-attachments-safe/message-attachments-safe/0.bin",
329 },
330 {
331 type: "image",
332 name: "not-image.png",
333 mimeType: "image/png",
334 sizeBytes: 5,
335 dataUrl: "data:text/plain;base64,SGVsbG8=",
336 },
337 ]);
338
339 const firstAttachmentPath = path.join(
340 stateDir,
341 "attachments",
342 "thread-attachments-safe",
343 "message-attachments-safe",
344 "0.bin",
345 );
346 assert.equal(fs.existsSync(firstAttachmentPath), true);
347 assert.deepEqual(fs.readFileSync(firstAttachmentPath), Buffer.from("SGVsbG8=", "base64"));
348
349 const secondAttachmentPath = path.join(
350 stateDir,
351 "attachments",
352 "thread-attachments-safe",
353 "message-attachments-safe",
354 "1.png",
355 );
356 assert.equal(fs.existsSync(secondAttachmentPath), false);
357 fs.rmSync(stateDir, { recursive: true, force: true });
358 }),
359 );
360
250 it.effect("resumes from projector last_applied_sequence without replaying older events", () =>361 it.effect("resumes from projector last_applied_sequence without replaying older events", () =>
251 Effect.gen(function* () {362 Effect.gen(function* () {
252 const projectionPipeline = yield* OrchestrationProjectionPipeline;363 const projectionPipeline = yield* OrchestrationProjectionPipeline;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+26-5
...@@ -70,6 +70,17 @@ const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {...@@ -70,6 +70,17 @@ const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
70 "image/tiff": ".tiff",70 "image/tiff": ".tiff",
71 "image/heic": ".heic",71 "image/heic": ".heic",
72};72};
73const SAFE_IMAGE_FILE_EXTENSIONS = new Set([
74 ".jpg",
75 ".jpeg",
76 ".png",
77 ".gif",
78 ".webp",
79 ".bmp",
80 ".tiff",
81 ".svg",
82 ".ico",
83]);
7384
74function parseBase64DataUrl(85function parseBase64DataUrl(
75 dataUrl: string,86 dataUrl: string,
...@@ -87,14 +98,18 @@ function parseBase64DataUrl(...@@ -87,14 +98,18 @@ function parseBase64DataUrl(
87}98}
8899
89function inferImageExtension(attachment: Extract<ChatAttachment, { type: "image" }>): string {100function inferImageExtension(attachment: Extract<ChatAttachment, { type: "image" }>): string {
90 const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[attachment.mimeType.toLowerCase()];101 const normalizedMimeType = attachment.mimeType.trim().toLowerCase();
91 if (fromMime) {102 if (normalizedMimeType.startsWith("image/")) {
92 return fromMime;103 const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[normalizedMimeType];
104 if (fromMime) {
105 return fromMime;
106 }
93 }107 }
108
94 const fromName = attachment.name.includes(".")109 const fromName = attachment.name.includes(".")
95 ? `.${attachment.name.split(".").slice(-1)[0]?.toLowerCase() ?? ""}`110 ? `.${attachment.name.split(".").slice(-1)[0]?.toLowerCase() ?? ""}`
96 : "";111 : "";
97 if (/^\.[a-z0-9]{1,12}$/.test(fromName)) {112 if (/^\.[a-z0-9]{1,12}$/.test(fromName) && SAFE_IMAGE_FILE_EXTENSIONS.has(fromName)) {
98 return fromName;113 return fromName;
99 }114 }
100 return ".bin";115 return ".bin";
...@@ -132,13 +147,19 @@ const materializeAttachmentsForProjection = Effect.fn(function* (input: {...@@ -132,13 +147,19 @@ const materializeAttachmentsForProjection = Effect.fn(function* (input: {
132 if (!parsed) {147 if (!parsed) {
133 return attachment;148 return attachment;
134 }149 }
150 if (!parsed.mimeType.startsWith("image/")) {
151 return attachment;
152 }
135153
136 const bytes = Buffer.from(parsed.base64, "base64");154 const bytes = Buffer.from(parsed.base64, "base64");
137 if (bytes.byteLength === 0) {155 if (bytes.byteLength === 0) {
138 return attachment;156 return attachment;
139 }157 }
140158
141 const fileName = `${index}${inferImageExtension(attachment)}`;159 const fileName = `${index}${inferImageExtension({
160 ...attachment,
161 mimeType: parsed.mimeType,
162 })}`;
142 const relativePath = `${threadSegment}/${messageSegment}/${fileName}`;163 const relativePath = `${threadSegment}/${messageSegment}/${fileName}`;
143 const absolutePath = path.join(attachmentsRootDir, threadSegment, messageSegment, fileName);164 const absolutePath = path.join(attachmentsRootDir, threadSegment, messageSegment, fileName);
144 yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true });165 yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true });
apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts created+106
...@@ -0,0 +1,106 @@
1import { MessageId, ThreadId } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";
3import { Effect, Layer } from "effect";
4
5import { ProjectionThreadMessageRepository } from "../Services/ProjectionThreadMessages.ts";
6import { ProjectionThreadMessageRepositoryLive } from "./ProjectionThreadMessages.ts";
7import { SqlitePersistenceMemory } from "./Sqlite.ts";
8
9const layer = it.layer(
10 ProjectionThreadMessageRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
11);
12
13layer("ProjectionThreadMessageRepository", (it) => {
14 it.effect("preserves existing attachments when upsert omits attachments", () =>
15 Effect.gen(function* () {
16 const repository = yield* ProjectionThreadMessageRepository;
17 const threadId = ThreadId.makeUnsafe("thread-preserve-attachments");
18 const messageId = MessageId.makeUnsafe("message-preserve-attachments");
19 const createdAt = "2026-02-28T19:00:00.000Z";
20 const updatedAt = "2026-02-28T19:00:01.000Z";
21 const persistedAttachments = [
22 {
23 type: "image" as const,
24 name: "example.png",
25 mimeType: "image/png",
26 sizeBytes: 5,
27 dataUrl: "/attachments/thread-preserve-attachments/message-preserve-attachments/0.png",
28 },
29 ];
30
31 yield* repository.upsert({
32 messageId,
33 threadId,
34 turnId: null,
35 role: "user",
36 text: "initial",
37 attachments: persistedAttachments,
38 isStreaming: false,
39 createdAt,
40 updatedAt,
41 });
42
43 yield* repository.upsert({
44 messageId,
45 threadId,
46 turnId: null,
47 role: "user",
48 text: "updated",
49 isStreaming: false,
50 createdAt,
51 updatedAt: "2026-02-28T19:00:02.000Z",
52 });
53
54 const rows = yield* repository.listByThreadId({ threadId });
55 assert.equal(rows.length, 1);
56 assert.equal(rows[0]?.text, "updated");
57 assert.deepEqual(rows[0]?.attachments, persistedAttachments);
58 }),
59 );
60
61 it.effect("allows explicit attachment clearing with an empty array", () =>
62 Effect.gen(function* () {
63 const repository = yield* ProjectionThreadMessageRepository;
64 const threadId = ThreadId.makeUnsafe("thread-clear-attachments");
65 const messageId = MessageId.makeUnsafe("message-clear-attachments");
66 const createdAt = "2026-02-28T19:10:00.000Z";
67
68 yield* repository.upsert({
69 messageId,
70 threadId,
71 turnId: null,
72 role: "assistant",
73 text: "with attachment",
74 attachments: [
75 {
76 type: "image",
77 name: "example.png",
78 mimeType: "image/png",
79 sizeBytes: 5,
80 dataUrl: "/attachments/thread-clear-attachments/message-clear-attachments/0.png",
81 },
82 ],
83 isStreaming: false,
84 createdAt,
85 updatedAt: "2026-02-28T19:10:01.000Z",
86 });
87
88 yield* repository.upsert({
89 messageId,
90 threadId,
91 turnId: null,
92 role: "assistant",
93 text: "cleared",
94 attachments: [],
95 isStreaming: false,
96 createdAt,
97 updatedAt: "2026-02-28T19:10:02.000Z",
98 });
99
100 const rows = yield* repository.listByThreadId({ threadId });
101 assert.equal(rows.length, 1);
102 assert.equal(rows[0]?.text, "cleared");
103 assert.deepEqual(rows[0]?.attachments, []);
104 }),
105 );
106});
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts+17-5
...@@ -23,8 +23,9 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {...@@ -23,8 +23,9 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
2323
24 const upsertProjectionThreadMessageRow = SqlSchema.void({24 const upsertProjectionThreadMessageRow = SqlSchema.void({
25 Request: ProjectionThreadMessage,25 Request: ProjectionThreadMessage,
26 execute: (row) =>26 execute: (row) => {
27 sql`27 const nextAttachmentsJson = row.attachments !== undefined ? JSON.stringify(row.attachments) : null;
28 return sql`
28 INSERT INTO projection_thread_messages (29 INSERT INTO projection_thread_messages (
29 message_id,30 message_id,
30 thread_id,31 thread_id,
...@@ -42,7 +43,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {...@@ -42,7 +43,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
42 ${row.turnId},43 ${row.turnId},
43 ${row.role},44 ${row.role},
44 ${row.text},45 ${row.text},
45 ${row.attachments !== undefined ? JSON.stringify(row.attachments) : null},46 COALESCE(
47 ${nextAttachmentsJson},
48 (
49 SELECT attachments_json
50 FROM projection_thread_messages
51 WHERE message_id = ${row.messageId}
52 )
53 ),
46 ${row.isStreaming ? 1 : 0},54 ${row.isStreaming ? 1 : 0},
47 ${row.createdAt},55 ${row.createdAt},
48 ${row.updatedAt}56 ${row.updatedAt}
...@@ -53,11 +61,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {...@@ -53,11 +61,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
53 turn_id = excluded.turn_id,61 turn_id = excluded.turn_id,
54 role = excluded.role,62 role = excluded.role,
55 text = excluded.text,63 text = excluded.text,
56 attachments_json = excluded.attachments_json,64 attachments_json = COALESCE(
65 excluded.attachments_json,
66 projection_thread_messages.attachments_json
67 ),
57 is_streaming = excluded.is_streaming,68 is_streaming = excluded.is_streaming,
58 created_at = excluded.created_at,69 created_at = excluded.created_at,
59 updated_at = excluded.updated_at70 updated_at = excluded.updated_at
60 `,71 `;
72 },
61 });73 });
6274
63 const listProjectionThreadMessageRows = SqlSchema.findAll({75 const listProjectionThreadMessageRows = SqlSchema.findAll({
apps/server/src/wsServer.test.ts+62-1
...@@ -296,6 +296,36 @@ async function waitForPush(...@@ -296,6 +296,36 @@ async function waitForPush(
296 return take(maxMessages);296 return take(maxMessages);
297}297}
298298
299async function requestPath(
300 port: number,
301 requestPath: string,
302): Promise<{ statusCode: number; body: string }> {
303 return new Promise((resolve, reject) => {
304 const req = Http.request(
305 {
306 hostname: "127.0.0.1",
307 port,
308 path: requestPath,
309 method: "GET",
310 },
311 (res) => {
312 const chunks: Buffer[] = [];
313 res.on("data", (chunk) => {
314 chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
315 });
316 res.on("end", () => {
317 resolve({
318 statusCode: res.statusCode ?? 0,
319 body: Buffer.concat(chunks).toString("utf8"),
320 });
321 });
322 },
323 );
324 req.once("error", reject);
325 req.end();
326 });
327}
328
299function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {329function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {
300 const resolved: Array<ResolvedKeybindingsConfig[number]> = [];330 const resolved: Array<ResolvedKeybindingsConfig[number]> = [];
301 for (const binding of bindings) {331 for (const binding of bindings) {
...@@ -331,6 +361,7 @@ describe("WebSocket Server", () => {...@@ -331,6 +361,7 @@ describe("WebSocket Server", () => {
331 devUrl?: string;361 devUrl?: string;
332 authToken?: string;362 authToken?: string;
333 stateDir?: string;363 stateDir?: string;
364 staticDir?: string;
334 providerLayer?: Layer.Layer<ProviderService, never>;365 providerLayer?: Layer.Layer<ProviderService, never>;
335 open?: OpenShape;366 open?: OpenShape;
336 gitManager?: GitManagerShape;367 gitManager?: GitManagerShape;
...@@ -354,7 +385,7 @@ describe("WebSocket Server", () => {...@@ -354,7 +385,7 @@ describe("WebSocket Server", () => {
354 cwd: options.cwd ?? "/test/project",385 cwd: options.cwd ?? "/test/project",
355 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),386 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
356 stateDir,387 stateDir,
357 staticDir: undefined,388 staticDir: options.staticDir,
358 devUrl: options.devUrl ? new URL(options.devUrl) : undefined,389 devUrl: options.devUrl ? new URL(options.devUrl) : undefined,
359 noBrowser: true,390 noBrowser: true,
360 authToken: options.authToken,391 authToken: options.authToken,
...@@ -456,6 +487,36 @@ describe("WebSocket Server", () => {...@@ -456,6 +487,36 @@ describe("WebSocket Server", () => {
456 expect(bytes).toEqual(Buffer.from("hello-attachment"));487 expect(bytes).toEqual(Buffer.from("hello-attachment"));
457 });488 });
458489
490 it("serves static index for root path", async () => {
491 const stateDir = makeTempDir("t3code-state-static-root-");
492 const staticDir = makeTempDir("t3code-static-root-");
493 fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>static-root</h1>", "utf8");
494
495 server = await createTestServer({ cwd: "/test/project", stateDir, staticDir });
496 const addr = server.address();
497 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
498 expect(port).toBeGreaterThan(0);
499
500 const response = await fetch(`http://127.0.0.1:${port}/`);
501 expect(response.status).toBe(200);
502 expect(await response.text()).toContain("static-root");
503 });
504
505 it("rejects static path traversal attempts", async () => {
506 const stateDir = makeTempDir("t3code-state-static-traversal-");
507 const staticDir = makeTempDir("t3code-static-traversal-");
508 fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>safe</h1>", "utf8");
509
510 server = await createTestServer({ cwd: "/test/project", stateDir, staticDir });
511 const addr = server.address();
512 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
513 expect(port).toBeGreaterThan(0);
514
515 const response = await requestPath(port, "/..%2f..%2fetc/passwd");
516 expect(response.statusCode).toBe(400);
517 expect(response.body).toBe("Invalid static file path");
518 });
519
459 it("bootstraps the cwd project on startup when enabled", async () => {520 it("bootstraps the cwd project on startup when enabled", async () => {
460 server = await createTestServer({521 server = await createTestServer({
461 cwd: "/test/bootstrap-workspace",522 cwd: "/test/bootstrap-workspace",
apps/server/src/wsServer.ts+36-3
...@@ -315,17 +315,50 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -315,17 +315,50 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
315 return;315 return;
316 }316 }
317317
318 let filePath = path.join(staticDir, url.pathname);318 const staticRoot = path.resolve(staticDir);
319 const staticRequestPath = url.pathname === "/" ? "/index.html" : url.pathname;
320 const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");
321 const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");
322 const staticRelativePath = path
323 .normalize(rawStaticRelativePath)
324 .replace(/^[/\\]+/, "");
325 const hasPathTraversalSegment = staticRelativePath.startsWith("..");
326 if (
327 staticRelativePath.length === 0 ||
328 hasRawLeadingParentSegment ||
329 hasPathTraversalSegment ||
330 staticRelativePath.includes("\0")
331 ) {
332 respond(400, { "Content-Type": "text/plain" }, "Invalid static file path");
333 return;
334 }
335
336 const isWithinStaticRoot = (candidate: string) =>
337 candidate === staticRoot ||
338 candidate.startsWith(
339 staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`,
340 );
341
342 let filePath = path.resolve(staticRoot, staticRelativePath);
343 if (!isWithinStaticRoot(filePath)) {
344 respond(400, { "Content-Type": "text/plain" }, "Invalid static file path");
345 return;
346 }
347
319 const ext = path.extname(filePath);348 const ext = path.extname(filePath);
320 if (!ext) {349 if (!ext) {
321 filePath = path.join(filePath, "index.html");350 filePath = path.resolve(filePath, "index.html");
351 if (!isWithinStaticRoot(filePath)) {
352 respond(400, { "Content-Type": "text/plain" }, "Invalid static file path");
353 return;
354 }
322 }355 }
323356
324 const fileInfo = yield* fileSystem357 const fileInfo = yield* fileSystem
325 .stat(filePath)358 .stat(filePath)
326 .pipe(Effect.catch(() => Effect.succeed(null)));359 .pipe(Effect.catch(() => Effect.succeed(null)));
327 if (!fileInfo || fileInfo.type !== "File") {360 if (!fileInfo || fileInfo.type !== "File") {
328 const indexPath = path.join(staticDir, "index.html");361 const indexPath = path.resolve(staticRoot, "index.html");
329 const indexData = yield* fileSystem362 const indexData = yield* fileSystem
330 .readFile(indexPath)363 .readFile(indexPath)
331 .pipe(Effect.catch(() => Effect.succeed(null)));364 .pipe(Effect.catch(() => Effect.succeed(null)));
apps/web/src/store.ts+6-1
...@@ -210,7 +210,12 @@ function resolveWsHttpOrigin(): string {...@@ -210,7 +210,12 @@ function resolveWsHttpOrigin(): string {
210210
211 try {211 try {
212 const wsUrl = new URL(wsCandidate);212 const wsUrl = new URL(wsCandidate);
213 const protocol = wsUrl.protocol === "wss:" ? "https:" : "http:";213 const protocol =
214 wsUrl.protocol === "wss:"
215 ? "https:"
216 : wsUrl.protocol === "ws:"
217 ? "http:"
218 : wsUrl.protocol;
214 return `${protocol}//${wsUrl.host}`;219 return `${protocol}//${wsUrl.host}`;
215 } catch {220 } catch {
216 return window.location.origin;221 return window.location.origin;