diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 78307f263d3617f0ac5597d949b07d2ba6ce9c5f..64aa510e19337efc60e8e6d6377f14f04807f147 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -247,6 +247,117 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { }), ); + it.effect("materializes only image data URLs and uses a safe extension whitelist", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-")); + + const serverConfig = { + mode: "web", + port: 0, + host: undefined, + cwd: "/tmp/project-attachments", + keybindingsConfigPath: path.join(stateDir, "keybindings.json"), + stateDir, + staticDir: undefined, + devUrl: undefined, + noBrowser: true, + authToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + } satisfies ServerConfigShape; + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-attachments-safe"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-attachments-safe"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-attachments-safe"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-attachments-safe"), + messageId: MessageId.makeUnsafe("message-attachments-safe"), + role: "user", + text: "Inspect this", + attachments: [ + { + type: "image", + name: "untrusted.exe", + mimeType: "image/x-unknown", + sizeBytes: 5, + dataUrl: "data:image/x-unknown;base64,SGVsbG8=", + }, + { + type: "image", + name: "not-image.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:text/plain;base64,SGVsbG8=", + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); + + const rows = yield* sql<{ + readonly attachmentsJson: string | null; + }>` + SELECT + attachments_json AS "attachmentsJson" + FROM projection_thread_messages + WHERE message_id = 'message-attachments-safe' + `; + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ + { + type: "image", + name: "untrusted.exe", + mimeType: "image/x-unknown", + sizeBytes: 5, + dataUrl: "/attachments/thread-attachments-safe/message-attachments-safe/0.bin", + }, + { + type: "image", + name: "not-image.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:text/plain;base64,SGVsbG8=", + }, + ]); + + const firstAttachmentPath = path.join( + stateDir, + "attachments", + "thread-attachments-safe", + "message-attachments-safe", + "0.bin", + ); + assert.equal(fs.existsSync(firstAttachmentPath), true); + assert.deepEqual(fs.readFileSync(firstAttachmentPath), Buffer.from("SGVsbG8=", "base64")); + + const secondAttachmentPath = path.join( + stateDir, + "attachments", + "thread-attachments-safe", + "message-attachments-safe", + "1.png", + ); + assert.equal(fs.existsSync(secondAttachmentPath), false); + fs.rmSync(stateDir, { recursive: true, force: true }); + }), + ); + it.effect("resumes from projector last_applied_sequence without replaying older events", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 229f38fc66130d64603d5a48496e251cc7942ecd..fd6deebb5a7a126beae68da10e8a408dde50b542 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -70,6 +70,17 @@ const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { "image/tiff": ".tiff", "image/heic": ".heic", }; +const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".webp", + ".bmp", + ".tiff", + ".svg", + ".ico", +]); function parseBase64DataUrl( dataUrl: string, @@ -87,14 +98,18 @@ function parseBase64DataUrl( } function inferImageExtension(attachment: Extract): string { - const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[attachment.mimeType.toLowerCase()]; - if (fromMime) { - return fromMime; + const normalizedMimeType = attachment.mimeType.trim().toLowerCase(); + if (normalizedMimeType.startsWith("image/")) { + const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[normalizedMimeType]; + if (fromMime) { + return fromMime; + } } + const fromName = attachment.name.includes(".") ? `.${attachment.name.split(".").slice(-1)[0]?.toLowerCase() ?? ""}` : ""; - if (/^\.[a-z0-9]{1,12}$/.test(fromName)) { + if (/^\.[a-z0-9]{1,12}$/.test(fromName) && SAFE_IMAGE_FILE_EXTENSIONS.has(fromName)) { return fromName; } return ".bin"; @@ -132,13 +147,19 @@ const materializeAttachmentsForProjection = Effect.fn(function* (input: { if (!parsed) { return attachment; } + if (!parsed.mimeType.startsWith("image/")) { + return attachment; + } const bytes = Buffer.from(parsed.base64, "base64"); if (bytes.byteLength === 0) { return attachment; } - const fileName = `${index}${inferImageExtension(attachment)}`; + const fileName = `${index}${inferImageExtension({ + ...attachment, + mimeType: parsed.mimeType, + })}`; const relativePath = `${threadSegment}/${messageSegment}/${fileName}`; const absolutePath = path.join(attachmentsRootDir, threadSegment, messageSegment, fileName); yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..194c2943fec25b71a26b2d1394e91973121f25b9 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -0,0 +1,106 @@ +import { MessageId, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { ProjectionThreadMessageRepository } from "../Services/ProjectionThreadMessages.ts"; +import { ProjectionThreadMessageRepositoryLive } from "./ProjectionThreadMessages.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; + +const layer = it.layer( + ProjectionThreadMessageRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +layer("ProjectionThreadMessageRepository", (it) => { + it.effect("preserves existing attachments when upsert omits attachments", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.makeUnsafe("thread-preserve-attachments"); + const messageId = MessageId.makeUnsafe("message-preserve-attachments"); + const createdAt = "2026-02-28T19:00:00.000Z"; + const updatedAt = "2026-02-28T19:00:01.000Z"; + const persistedAttachments = [ + { + type: "image" as const, + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "/attachments/thread-preserve-attachments/message-preserve-attachments/0.png", + }, + ]; + + yield* repository.upsert({ + messageId, + threadId, + turnId: null, + role: "user", + text: "initial", + attachments: persistedAttachments, + isStreaming: false, + createdAt, + updatedAt, + }); + + yield* repository.upsert({ + messageId, + threadId, + turnId: null, + role: "user", + text: "updated", + isStreaming: false, + createdAt, + updatedAt: "2026-02-28T19:00:02.000Z", + }); + + const rows = yield* repository.listByThreadId({ threadId }); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.text, "updated"); + assert.deepEqual(rows[0]?.attachments, persistedAttachments); + }), + ); + + it.effect("allows explicit attachment clearing with an empty array", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.makeUnsafe("thread-clear-attachments"); + const messageId = MessageId.makeUnsafe("message-clear-attachments"); + const createdAt = "2026-02-28T19:10:00.000Z"; + + yield* repository.upsert({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "with attachment", + attachments: [ + { + type: "image", + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "/attachments/thread-clear-attachments/message-clear-attachments/0.png", + }, + ], + isStreaming: false, + createdAt, + updatedAt: "2026-02-28T19:10:01.000Z", + }); + + yield* repository.upsert({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "cleared", + attachments: [], + isStreaming: false, + createdAt, + updatedAt: "2026-02-28T19:10:02.000Z", + }); + + const rows = yield* repository.listByThreadId({ threadId }); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.text, "cleared"); + assert.deepEqual(rows[0]?.attachments, []); + }), + ); +}); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 2993d9b2c96d3417e05e41ec43a15a716c5c3ba2..dd30f35f92ff775da54fe7f0f5f2d45ac33ddfa8 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -23,8 +23,9 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { const upsertProjectionThreadMessageRow = SqlSchema.void({ Request: ProjectionThreadMessage, - execute: (row) => - sql` + execute: (row) => { + const nextAttachmentsJson = row.attachments !== undefined ? JSON.stringify(row.attachments) : null; + return sql` INSERT INTO projection_thread_messages ( message_id, thread_id, @@ -42,7 +43,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ${row.turnId}, ${row.role}, ${row.text}, - ${row.attachments !== undefined ? JSON.stringify(row.attachments) : null}, + COALESCE( + ${nextAttachmentsJson}, + ( + SELECT attachments_json + FROM projection_thread_messages + WHERE message_id = ${row.messageId} + ) + ), ${row.isStreaming ? 1 : 0}, ${row.createdAt}, ${row.updatedAt} @@ -53,11 +61,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { turn_id = excluded.turn_id, role = excluded.role, text = excluded.text, - attachments_json = excluded.attachments_json, + attachments_json = COALESCE( + excluded.attachments_json, + projection_thread_messages.attachments_json + ), is_streaming = excluded.is_streaming, created_at = excluded.created_at, updated_at = excluded.updated_at - `, + `; + }, }); const listProjectionThreadMessageRows = SqlSchema.findAll({ diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 84113f00c98e84ea89d89fde4f9a54edee58251c..07ef0ab7db98f56172b4a1c8b49815a65bd54301 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -296,6 +296,36 @@ async function waitForPush( return take(maxMessages); } +async function requestPath( + port: number, + requestPath: string, +): Promise<{ statusCode: number; body: string }> { + return new Promise((resolve, reject) => { + const req = Http.request( + { + hostname: "127.0.0.1", + port, + path: requestPath, + method: "GET", + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + res.on("end", () => { + resolve({ + statusCode: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString("utf8"), + }); + }); + }, + ); + req.once("error", reject); + req.end(); + }); +} + function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { const resolved: Array = []; for (const binding of bindings) { @@ -331,6 +361,7 @@ describe("WebSocket Server", () => { devUrl?: string; authToken?: string; stateDir?: string; + staticDir?: string; providerLayer?: Layer.Layer; open?: OpenShape; gitManager?: GitManagerShape; @@ -354,7 +385,7 @@ describe("WebSocket Server", () => { cwd: options.cwd ?? "/test/project", keybindingsConfigPath: path.join(stateDir, "keybindings.json"), stateDir, - staticDir: undefined, + staticDir: options.staticDir, devUrl: options.devUrl ? new URL(options.devUrl) : undefined, noBrowser: true, authToken: options.authToken, @@ -456,6 +487,36 @@ describe("WebSocket Server", () => { expect(bytes).toEqual(Buffer.from("hello-attachment")); }); + it("serves static index for root path", async () => { + const stateDir = makeTempDir("t3code-state-static-root-"); + const staticDir = makeTempDir("t3code-static-root-"); + fs.writeFileSync(path.join(staticDir, "index.html"), "

static-root

", "utf8"); + + server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const response = await fetch(`http://127.0.0.1:${port}/`); + expect(response.status).toBe(200); + expect(await response.text()).toContain("static-root"); + }); + + it("rejects static path traversal attempts", async () => { + const stateDir = makeTempDir("t3code-state-static-traversal-"); + const staticDir = makeTempDir("t3code-static-traversal-"); + fs.writeFileSync(path.join(staticDir, "index.html"), "

safe

", "utf8"); + + server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const response = await requestPath(port, "/..%2f..%2fetc/passwd"); + expect(response.statusCode).toBe(400); + expect(response.body).toBe("Invalid static file path"); + }); + it("bootstraps the cwd project on startup when enabled", async () => { server = await createTestServer({ cwd: "/test/bootstrap-workspace", diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 5cf4769b5c932a3da0b0d0827e8db2ba80622484..e65f1379a02cfccd0b40af8d9174b2b5e1dda188 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -315,17 +315,50 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return; } - let filePath = path.join(staticDir, url.pathname); + const staticRoot = path.resolve(staticDir); + const staticRequestPath = url.pathname === "/" ? "/index.html" : url.pathname; + const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); + const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); + const staticRelativePath = path + .normalize(rawStaticRelativePath) + .replace(/^[/\\]+/, ""); + const hasPathTraversalSegment = staticRelativePath.startsWith(".."); + if ( + staticRelativePath.length === 0 || + hasRawLeadingParentSegment || + hasPathTraversalSegment || + staticRelativePath.includes("\0") + ) { + respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); + return; + } + + const isWithinStaticRoot = (candidate: string) => + candidate === staticRoot || + candidate.startsWith( + staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`, + ); + + let filePath = path.resolve(staticRoot, staticRelativePath); + if (!isWithinStaticRoot(filePath)) { + respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); + return; + } + const ext = path.extname(filePath); if (!ext) { - filePath = path.join(filePath, "index.html"); + filePath = path.resolve(filePath, "index.html"); + if (!isWithinStaticRoot(filePath)) { + respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); + return; + } } const fileInfo = yield* fileSystem .stat(filePath) .pipe(Effect.catch(() => Effect.succeed(null))); if (!fileInfo || fileInfo.type !== "File") { - const indexPath = path.join(staticDir, "index.html"); + const indexPath = path.resolve(staticRoot, "index.html"); const indexData = yield* fileSystem .readFile(indexPath) .pipe(Effect.catch(() => Effect.succeed(null))); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 848c98fd016deda823e0c88a7b51eedfc2c17368..cd7ed5f98562a0e325b85811ea12a4e946faea7f 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -210,7 +210,12 @@ function resolveWsHttpOrigin(): string { try { const wsUrl = new URL(wsCandidate); - const protocol = wsUrl.protocol === "wss:" ? "https:" : "http:"; + const protocol = + wsUrl.protocol === "wss:" + ? "https:" + : wsUrl.protocol === "ws:" + ? "http:" + : wsUrl.protocol; return `${protocol}//${wsUrl.host}`; } catch { return window.location.origin;