From e3688775eb38266af1d72db16e9e6dcb82b31b82 Mon Sep 17 00:00:00 2001 From: "Sheng Zhao (SPEECH)" Date: Fri, 6 Nov 2026 00:19:08 +0800 Subject: [PATCH] Reject malformed request targets in local Node servers Return HTTP 400 for invalid URL parsing without terminating the calculator or GPT Live server. Cover continued service and token authorization with offline regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/cost-calculator-pages.yml | 2 +- samples/gpt_live/js/README.md | 5 ++ samples/gpt_live/js/server.mjs | 9 ++- samples/gpt_live/js/test/server.test.mjs | 28 +++++++ tools/cost_calculator/README.md | 7 +- tools/cost_calculator/server.mjs | 9 ++- tools/cost_calculator/server.test.mjs | 81 +++++++++++++++++++++ 7 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 tools/cost_calculator/server.test.mjs diff --git a/.github/workflows/cost-calculator-pages.yml b/.github/workflows/cost-calculator-pages.yml index a0ed978..6de710b 100644 --- a/.github/workflows/cost-calculator-pages.yml +++ b/.github/workflows/cost-calculator-pages.yml @@ -27,7 +27,7 @@ jobs: working-directory: tools/cost_calculator shell: pwsh run: | - node --test calculator.test.mjs + node --test calculator.test.mjs server.test.mjs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } node --check app.mjs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/samples/gpt_live/js/README.md b/samples/gpt_live/js/README.md index e076290..1b673dd 100644 --- a/samples/gpt_live/js/README.md +++ b/samples/gpt_live/js/README.md @@ -128,6 +128,9 @@ only and answers `POST /token` only when the request's `Host` and `Origin` are i `localhost` or `127.0.0.1` address, so other websites, including ones that rebind their DNS name to `127.0.0.1`, cannot get a token. This suits a local development sample; a hosted app would sign users in with their own identity, for example with MSAL.js. +Request targets that cannot be parsed as URLs receive HTTP 400 without stopping +the server or requesting a token. Later valid requests still work, and token +requests must still pass the same-origin checks. #### Why the sample supplies its own WebSocket transport @@ -157,6 +160,8 @@ Run the offline tests (no Azure resources, browser, or audio devices). They driv Projects SDK client: the server's agent calls go to an in-memory HTTP client, and the page's Live sessions talk to a scripted WebSocket transport. The browser transport runs on Node.js's built-in WebSocket against a local `ws` server. +HTTP regressions cover malformed request targets followed by valid page, +configuration, and same-origin token requests. ```powershell npm test diff --git a/samples/gpt_live/js/server.mjs b/samples/gpt_live/js/server.mjs index 1b39598..6c33868 100644 --- a/samples/gpt_live/js/server.mjs +++ b/samples/gpt_live/js/server.mjs @@ -74,11 +74,18 @@ function describeTokenFailure(error) { */ export function createServer({ credential, agent, endpoint, keepAgent = true, log = () => {} }) { const server = http.createServer(async (request, response) => { - const { pathname } = new URL(request.url, "http://localhost"); const send = (status, type, body) => { response.writeHead(status, { "content-type": type, "cache-control": "no-store" }); response.end(body); }; + let pathname; + try { + pathname = new URL(request.url, "http://localhost").pathname; + } catch (error) { + if (error.code !== "ERR_INVALID_URL") throw error; + send(400, "text/plain", "Bad request"); + return; + } if (request.method === "GET" && pathname === "/config") { send( 200, diff --git a/samples/gpt_live/js/test/server.test.mjs b/samples/gpt_live/js/test/server.test.mjs index eb44bb6..f1008e2 100644 --- a/samples/gpt_live/js/test/server.test.mjs +++ b/samples/gpt_live/js/test/server.test.mjs @@ -55,8 +55,10 @@ function request(port, path, { method = "GET", headers = {} } = {}) { let body = ""; response.setEncoding("utf8"); response.on("data", (chunk) => (body += chunk)); + response.on("error", reject); response.on("end", () => resolve({ status: response.statusCode, type: response.headers["content-type"], body })); }); + outgoing.setTimeout(5000, () => outgoing.destroy(new Error("Request timed out"))); outgoing.on("error", reject); outgoing.end(); }); @@ -70,6 +72,32 @@ const ownPage = (port, host = "localhost") => ({ }); describe("HTTP surface", () => { + it("rejects malformed targets without losing subsequent page, config, or token requests", () => + withServer({}, async ({ running: { port }, scopes }) => { + for (const target of ["//[", "//%zz", "http://[", "http://localhost:bad/"]) { + for (const method of ["GET", "POST"]) { + const bad = await request(port, target, { method }); + assert.equal(bad.status, 400, `${method} ${target}`); + assert.equal(bad.body, "Bad request"); + assert.equal(bad.type, "text/plain"); + assert.equal((await request(port, "/?value=%5B%20%25")).status, 200); + const config = await request(port, "/config?value=%5B%20%25"); + assert.equal(config.status, 200); + assert.equal(JSON.parse(config.body).endpoint, ENDPOINT); + } + } + assert.deepEqual(scopes, []); + for (const path of ["/missing%20file", "/%63onfig"]) { + assert.equal((await request(port, path)).status, 404, path); + } + assert.equal((await request(port, "/token", { method: "POST" })).status, 403); + assert.deepEqual(scopes, []); + const token = await request(port, "/token?value=%5B%20%25", { method: "POST", headers: ownPage(port) }); + assert.equal(token.status, 200); + assert.equal(JSON.parse(token.body).token, "offline-test-token"); + assert.deepEqual(scopes, [TOKEN_SCOPE]); + })); + it("serves the page, the bundled page script, and the agent configuration", async () => { const { running, project } = await start(); const { port } = running; diff --git a/tools/cost_calculator/README.md b/tools/cost_calculator/README.md index 88a8812..07e95fb 100644 --- a/tools/cost_calculator/README.md +++ b/tools/cost_calculator/README.md @@ -22,6 +22,8 @@ Open **http://127.0.0.1:8095**. To change the port, set `$env:PORT = "8096"` before running the command. The server listens only on the loopback interface. Stop it with Ctrl+C. Opening `index.html` as `file://` will not work because the page loads modules and the local price snapshot. +Request targets that cannot be parsed as URLs receive HTTP 400 without stopping +the local server; later valid requests are still served. Choose hosting, pipeline, and model; change call assumptions or deployment rates. The tool shows per-call, per-minute, call-volume list prices, component costs, and @@ -210,5 +212,8 @@ captured. ## Checks ```powershell -node --test tools\cost_calculator\calculator.test.mjs +node --test tools\cost_calculator\calculator.test.mjs tools\cost_calculator\server.test.mjs ``` + +The server regression test starts the actual local Node process, checks malformed +request rejection and subsequent asset requests, then stops the process. diff --git a/tools/cost_calculator/server.mjs b/tools/cost_calculator/server.mjs index 7f0258f..43c8d1b 100644 --- a/tools/cost_calculator/server.mjs +++ b/tools/cost_calculator/server.mjs @@ -7,7 +7,14 @@ const mime = { html: "text/html", css: "text/css", mjs: "text/javascript", json: const port = Number(process.env.PORT || 8095); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT must be an integer from 1 to 65535."); const server = createServer(async (req, res) => { - const path = new URL(req.url, "http://localhost").pathname; + let path; + try { + path = new URL(req.url, "http://localhost").pathname; + } catch (error) { + if (error.code !== "ERR_INVALID_URL") throw error; + res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end("Bad request"); + return; + } const file = path === "/" ? "index.html" : path.slice(1); if (!["GET", "HEAD"].includes(req.method) || !allowed.has(file)) { res.writeHead(404).end("Not found"); diff --git a/tools/cost_calculator/server.test.mjs b/tools/cost_calculator/server.test.mjs new file mode 100644 index 0000000..a438481 --- /dev/null +++ b/tools/cost_calculator/server.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import http from "node:http"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +function request(port, path, method = "GET") { + return new Promise((resolve, reject) => { + const outgoing = http.request({ host: "127.0.0.1", port, path, method, agent: false }, response => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", chunk => { body += chunk; }); + response.on("error", reject); + response.on("end", () => resolve({ status: response.statusCode, headers: response.headers, body })); + }); + outgoing.setTimeout(5000, () => outgoing.destroy(new Error("Request timed out"))); + outgoing.on("error", reject); + outgoing.end(); + }); +} + +test("the calculator process rejects malformed targets and keeps serving assets", { timeout: 15000 }, async t => { + const reservation = http.createServer(); + reservation.listen(0, "127.0.0.1"); + await once(reservation, "listening"); + const { port } = reservation.address(); + await new Promise((resolve, reject) => reservation.close(error => error ? reject(error) : resolve())); + + const child = spawn(process.execPath, [fileURLToPath(new URL("./server.mjs", import.meta.url))], { + env: { ...process.env, PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + child.stderr.setEncoding("utf8").on("data", chunk => { stderr += chunk; }); + const closed = new Promise(resolve => child.once("close", resolve)); + t.after(async () => { + if (child.exitCode === null && child.signalCode === null) child.kill(); + await closed; + }); + await new Promise((resolve, reject) => { + let stdout = ""; + const timer = setTimeout(() => reject(new Error(`Server startup timed out: ${stderr}`)), 5000); + child.once("error", error => { clearTimeout(timer); reject(error); }); + child.once("exit", code => { + clearTimeout(timer); + reject(new Error(`Server exited during startup (${code}): ${stderr}`)); + }); + child.stdout.setEncoding("utf8").on("data", chunk => { + stdout += chunk; + if (stdout.includes(`http://127.0.0.1:${port}`)) { + clearTimeout(timer); + resolve(); + } + }); + }); + + for (const target of ["//[", "//%zz", "http://[", "http://localhost:bad/"]) { + for (const method of ["GET", "POST"]) { + const bad = await request(port, target, method); + assert.equal(bad.status, 400, `${method} ${target}`); + assert.equal(bad.body, "Bad request"); + assert.match(bad.headers["content-type"], /^text\/plain/); + const page = await request(port, "/?value=%5B%20%25"); + assert.equal(page.status, 200); + assert.match(page.body, /Voice Agent Cost Lab/); + } + } + for (const file of ["index.html", "styles.css", "app.mjs", "calculator.mjs", "prices.json", "favicon.svg"]) { + assert.equal((await request(port, `/${file}`)).status, 200, file); + const head = await request(port, `/${file}`, "HEAD"); + assert.equal(head.status, 200, file); + assert.equal(head.body, ""); + } + for (const path of ["/missing%20file", "/%69ndex.html", "/server.mjs"]) { + assert.equal((await request(port, path)).status, 404, path); + } + assert.equal((await request(port, "/", "POST")).status, 404); + assert.equal(child.exitCode, null, stderr); + assert.equal(stderr, ""); +});