diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 879ca66ca60a..5515072e2112 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -546,11 +546,17 @@ 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 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 cb2cf03659ef..d4d9db1a6cfe 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1761,6 +1761,45 @@ 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", + "..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", + "..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(`/${encodedName}`, { + 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;