From 2ac47b4bbc806677274ee04226380e07f02f63fb Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:27:57 +0000 Subject: [PATCH 1/2] fix(server): decode URL-encoded static asset paths --- apps/server/src/http.ts | 8 +++++++- apps/server/src/server.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 879ca66ca60a..2e95a58d303d 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -546,7 +546,13 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( const path = yield* Path.Path; const staticRoot = path.resolve(staticDir); - const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname; + let staticRequestPath: string; + try { + staticRequestPath = decodeURIComponent(url.value.pathname); + } catch { + return HttpServerResponse.text("Invalid static file path", { status: 400 }); + } + if (staticRequestPath === "/") staticRequestPath = "/index.html"; const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, ""); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index cb2cf03659ef..67aa7647e9ea 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1761,6 +1761,32 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("serves URL-encoded static filenames exactly once", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-encoded-" }); + yield* fs.writeFileString(path.join(staticDir, "index.html"), "fallback"); + for (const name of ["a space.js", "日本語.js", "literal%20.js", "hash#name.js"]) { + yield* fs.writeFileString(path.join(staticDir, name), `// ${name}`); + } + yield* buildAppUnderTest({ config: { staticDir } }); + for (const name of ["a space.js", "日本語.js", "literal%20.js", "hash#name.js"]) { + const response = yield* HttpClient.get(`/${encodeURIComponent(name)}`); + assert.equal(response.status, 200); + assert.equal(yield* response.text, `// ${name}`); + const head = yield* HttpClient.head(`/${encodeURIComponent(name)}`, { + headers: { "accept-encoding": "identity" }, + }); + assert.equal(head.status, 200); + assert.equal(head.headers["content-length"], String(Buffer.byteLength(`// ${name}`))); + } + for (const target of ["/%invalid.js", "/%2e%2e%2fsecret.txt", "/%00.js"]) { + assert.include([400, 404], (yield* HttpClient.get(target)).status, target); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("revalidates static files without sending unchanged bodies", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; From fd851c24f05de7cdcd45f5972e11899cace78392 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:51:14 +0000 Subject: [PATCH 2/2] fix(server): allow static filenames beginning with dots --- apps/server/src/http.ts | 4 ++-- apps/server/src/server.test.ts | 21 +++++++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 2e95a58d303d..5515072e2112 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -554,9 +554,9 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( } if (staticRequestPath === "/") staticRequestPath = "/index.html"; const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); - const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); + const hasRawLeadingParentSegment = /^\.\.(?:[/\\]|$)/.test(rawStaticRelativePath); const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, ""); - const hasPathTraversalSegment = staticRelativePath.startsWith(".."); + const hasPathTraversalSegment = /^\.\.(?:[/\\]|$)/.test(staticRelativePath); if ( staticRelativePath.length === 0 || hasRawLeadingParentSegment || diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 67aa7647e9ea..d4d9db1a6cfe 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1767,15 +1767,28 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const path = yield* Path.Path; const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-encoded-" }); yield* fs.writeFileString(path.join(staticDir, "index.html"), "fallback"); - for (const name of ["a space.js", "日本語.js", "literal%20.js", "hash#name.js"]) { + for (const name of [ + "a space.js", + "日本語.js", + "literal%20.js", + "hash#name.js", + "..config.js", + ]) { yield* fs.writeFileString(path.join(staticDir, name), `// ${name}`); } yield* buildAppUnderTest({ config: { staticDir } }); - for (const name of ["a space.js", "日本語.js", "literal%20.js", "hash#name.js"]) { - const response = yield* HttpClient.get(`/${encodeURIComponent(name)}`); + for (const name of [ + "a space.js", + "日本語.js", + "literal%20.js", + "hash#name.js", + "..config.js", + ]) { + const encodedName = encodeURIComponent(name).replaceAll(".", "%2e"); + const response = yield* HttpClient.get(`/${encodedName}`); assert.equal(response.status, 200); assert.equal(yield* response.text, `// ${name}`); - const head = yield* HttpClient.head(`/${encodeURIComponent(name)}`, { + const head = yield* HttpClient.head(`/${encodedName}`, { headers: { "accept-encoding": "identity" }, }); assert.equal(head.status, 200);