| author | |
| committer | |
| log | b5188e8ceaee3940d2fb3cd88c8fc2121fef1ae7 |
| tree | 7b6e3246022b3f1b868bb10ec869c317551928ec |
| parent | 992db1658633ff268be5d1167634966ae63ed859 |
- 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 requests7 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 | ); |
| 249 | 249 | ||
| 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 | }; |
| 73 | const 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 | ]); | ||
| 73 | 84 | ||
| 74 | function parseBase64DataUrl( | 85 | function parseBase64DataUrl( |
| 75 | dataUrl: string, | 86 | dataUrl: string, |
| ... | @@ -87,14 +98,18 @@ function parseBase64DataUrl( | ... | @@ -87,14 +98,18 @@ function parseBase64DataUrl( |
| 87 | } | 98 | } |
| 88 | 99 | ||
| 89 | function inferImageExtension(attachment: Extract<ChatAttachment, { type: "image" }>): string { | 100 | function 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 | } | ||
| 135 | 153 | ||
| 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 | } |
| 140 | 158 | ||
| 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 @@ | ||
| 1 | import { MessageId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import { Effect, Layer } from "effect"; | ||
| 4 | |||
| 5 | import { ProjectionThreadMessageRepository } from "../Services/ProjectionThreadMessages.ts"; | ||
| 6 | import { ProjectionThreadMessageRepositoryLive } from "./ProjectionThreadMessages.ts"; | ||
| 7 | import { SqlitePersistenceMemory } from "./Sqlite.ts"; | ||
| 8 | |||
| 9 | const layer = it.layer( | ||
| 10 | ProjectionThreadMessageRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), | ||
| 11 | ); | ||
| 12 | |||
| 13 | layer("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* () { |
| 23 | 23 | ||
| 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_at | 70 | updated_at = excluded.updated_at |
| 60 | `, | 71 | `; |
| 72 | }, | ||
| 61 | }); | 73 | }); |
| 62 | 74 | ||
| 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 | } |
| 298 | 298 | ||
| 299 | async 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 | |||
| 299 | function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { | 329 | function 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 | }); |
| 458 | 489 | ||
| 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 | } |
| 317 | 317 | ||
| 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 | } |
| 323 | 356 | ||
| 324 | const fileInfo = yield* fileSystem | 357 | 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* fileSystem | 362 | 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 { |
| 210 | 210 | ||
| 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; |