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, ""); +});