Fix browser requests through the ticket gate
- commit
- 22f961e9b77cc34d385e024723b406cadde85201
- parent
- 101161c9dc2f2a79907d902b0639bee6fd517408
- parent
- cd723ddabaf7c8660f1927543f64f2825dc79703
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | src/ | 152 | ++++++++++++++++++++---------- |
| M | tests/ | 40 | ++++++++ |
@@ -87,6 +87,35 @@ function unauthorized(): Response {
8787 });8888 }8989 90+const CORS_ALLOW_METHODS = "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS";91+92+function corsHeaders(req: Request): Headers {93+ const headers = new Headers({94+ "access-control-allow-origin": "*",95+ "access-control-allow-methods": CORS_ALLOW_METHODS,96+ });97+ const requestedHeaders = req.headers.get("access-control-request-headers");98+ if (requestedHeaders) headers.set("access-control-allow-headers", requestedHeaders);99+ if (req.headers.get("access-control-request-private-network") === "true") {100+ headers.set("access-control-allow-private-network", "true");101+ }102+ return headers;103+}104+105+function corsPreflight(req: Request): Response {106+ return new Response(null, { status: 204, headers: corsHeaders(req) });107+}108+109+function withCors(req: Request, response: Response): Response {110+ const headers = new Headers(response.headers);111+ for (const [name, value] of corsHeaders(req)) headers.set(name, value);112+ return new Response(response.body, {113+ status: response.status,114+ statusText: response.statusText,115+ headers,116+ });117+}118+90119 /** Pump two OPEN (or opening) WebSockets both ways until either closes.91120 * `backlog` holds client data that arrived before the upstream dial finished;92121 * flushed before the listener attaches (same task — no interleave). */@@ -383,30 +412,35 @@ export function startGate(options: GateOptions): Gate {
383412 const url = new URL(req.url);384413 const isUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";385414 const targetBase = options.target();415+ if (!isUpgrade && req.method === "OPTIONS") return corsPreflight(req);416+ const respond = (response: Response): Response =>417+ isUpgrade ? response : withCors(req, response);386418 387419 if (options.mode === "open") {388420 // Open nodes still serve the preflight so lofi's store classifier389421 // works against dev setups.390422 if (url.pathname === "/store-status" && !isUpgrade) {391- return storeStatus(targetBase, options.appId, options.adminSecret);423+ return respond(await storeStatus(targetBase, options.appId, options.adminSecret));392424 }393425 const path = url.pathname + url.search;394- return isUpgrade395- ? proxyWebSocket(req, targetBase, path).response396- : proxyHttp(req, targetBase, path);426+ return respond(427+ isUpgrade428+ ? proxyWebSocket(req, targetBase, path).response429+ : await proxyHttp(req, targetBase, path),430+ );397431 }398432 399433 // Ticket mode.400434 if (url.pathname === "/health" && !isUpgrade) {401- return proxyHttp(req, targetBase, "/health");435+ return respond(await proxyHttp(req, targetBase, "/health"));402436 }403437 const match = url.pathname.match(TICKET_PATH);404- if (!match) return new Response("not found", { status: 404 });438+ if (!match) return respond(new Response("not found", { status: 404 }));405439 const [, secret, rest] = match;406440 const verdict = await options.store.verify(secret);407441 if (verdict.status !== "valid") {408442 debug("rejected", verdict.status, url.pathname.slice(0, 24));409- return unauthorized();443+ return respond(unauthorized());410444 }411445 const scope = verdict.record.scope ?? "sync";412446 seen(verdict.record.id);@@ -419,26 +453,28 @@ export function startGate(options: GateOptions): Gate {
419453 // Liveness stays reachable with the bare secret — it reveals nothing420454 // a valid ticket holder could not learn from the open /health route.421455 if (strippedPath === "/health" && !isUpgrade) {422- return proxyHttp(req, targetBase, "/health");456+ return respond(await proxyHttp(req, targetBase, "/health"));423457 }424458 if (strippedPath === "/pop/challenge" && !isUpgrade) {425459 if (req.method !== "POST") {426- return new Response("method not allowed", { status: 405 });460+ return respond(new Response("method not allowed", { status: 405 }));427461 }428462 const challenge = mintChallenge(verdict.record.id);429- return new Response(430- JSON.stringify({431- v: 1,432- id: challenge.id,433- nonce: challenge.nonce,434- expiresIn: CHALLENGE_TTL_MS / 1000,435- }),436- { headers: { "content-type": "application/json" } },463+ return respond(464+ new Response(465+ JSON.stringify({466+ v: 1,467+ id: challenge.id,468+ nonce: challenge.nonce,469+ expiresIn: CHALLENGE_TTL_MS / 1000,470+ }),471+ { headers: { "content-type": "application/json" } },472+ ),437473 );438474 }439475 if (strippedPath === "/pop/answer" && !isUpgrade) {440476 if (req.method !== "POST") {441- return new Response("method not allowed", { status: 405 });477+ return respond(new Response("method not allowed", { status: 405 }));442478 }443479 let body: { id?: unknown; sig?: unknown } = {};444480 try {@@ -449,25 +485,27 @@ export function startGate(options: GateOptions): Gate {
449485 const token = await answerChallenge(verdict.record.id, verdict.record.pop, body);450486 if (token === null) {451487 debug("rejected pop answer", verdict.record.id);452- return unauthorized();488+ return respond(unauthorized());453489 }454490 debug("pop verified", verdict.record.id);455- return new Response(456- JSON.stringify({ v: 1, connect: token, expiresIn: CONNECT_TOKEN_TTL_MS / 1000 }),457- { headers: { "content-type": "application/json" } },491+ return respond(492+ new Response(493+ JSON.stringify({ v: 1, connect: token, expiresIn: CONNECT_TOKEN_TTL_MS / 1000 }),494+ { headers: { "content-type": "application/json" } },495+ ),458496 );459497 }460498 const connect = strippedPath.match(CONNECT_PATH);461499 if (!connect) {462500 debug("rejected pop-bound bare path", verdict.record.id);463- return unauthorized();501+ return respond(unauthorized());464502 }465503 const [, connectSecret, connectRest] = connect;466504 const tokenDigest = await hashSecret(connectSecret);467505 const token = connectTokens.get(tokenDigest);468506 if (!token || token.ticketId !== verdict.record.id || token.expiresAt < Date.now()) {469507 debug("rejected connect token", verdict.record.id);470- return unauthorized();508+ return respond(unauthorized());471509 }472510 // Sliding lifetime: each authenticated use extends the token.473511 token.expiresAt = Date.now() + CONNECT_TOKEN_TTL_MS;@@ -476,13 +514,15 @@ export function startGate(options: GateOptions): Gate {
476514 // The exchange only exists for bound tickets; a bearer ticket probing477515 // it is a client-configuration error, and the path must never leak478516 // upstream.479- return new Response(JSON.stringify({ error: "pop_not_bound" }), {480- status: 400,481- headers: { "content-type": "application/json" },482- });517+ return respond(518+ new Response(JSON.stringify({ error: "pop_not_bound" }), {519+ status: 400,520+ headers: { "content-type": "application/json" },521+ }),522+ );483523 }484524 if (strippedPath === "/store-status" && !isUpgrade) {485- return storeStatus(targetBase, options.appId, options.adminSecret);525+ return respond(await storeStatus(targetBase, options.appId, options.adminSecret));486526 }487527 // Scope-down exchange: a provision ticket mints a derived sync ticket488528 // (the lofi app persists that one at rest, keeping the provision ticket@@ -492,10 +532,10 @@ export function startGate(options: GateOptions): Gate {
492532 if (strippedPath === "/derive-sync-ticket" && !isUpgrade) {493533 if (scope !== "provision") {494534 debug("rejected sync-scope on derive", verdict.record.id);495- return unauthorized();535+ return respond(unauthorized());496536 }497537 if (req.method !== "POST") {498- return new Response("method not allowed", { status: 405 });538+ return respond(new Response("method not allowed", { status: 405 }));499539 }500540 let requested: string | undefined;501541 let deviceKey: { alg?: unknown; spki?: unknown } | undefined;@@ -513,18 +553,22 @@ export function startGate(options: GateOptions): Gate {
513553 let pop: { alg: "ES256"; spki: string } | undefined;514554 if (deviceKey !== undefined) {515555 if (deviceKey.alg !== "ES256" || typeof deviceKey.spki !== "string") {516- return new Response(JSON.stringify({ error: "invalid_device_key" }), {517- status: 400,518- headers: { "content-type": "application/json" },519- });556+ return respond(557+ new Response(JSON.stringify({ error: "invalid_device_key" }), {558+ status: 400,559+ headers: { "content-type": "application/json" },560+ }),561+ );520562 }521563 try {522564 await importPopKey(deviceKey.spki);523565 } catch {524- return new Response(JSON.stringify({ error: "invalid_device_key" }), {525- status: 400,526- headers: { "content-type": "application/json" },527- });566+ return respond(567+ new Response(JSON.stringify({ error: "invalid_device_key" }), {568+ status: 400,569+ headers: { "content-type": "application/json" },570+ }),571+ );528572 }529573 pop = { alg: "ES256", spki: deviceKey.spki };530574 }@@ -541,36 +585,40 @@ export function startGate(options: GateOptions): Gate {
541585 node: options.nodeTicket,542586 });543587 debug("derived sync ticket", derived.record.id, "from", parent.id, pop ? "pop" : "bearer");544- return new Response(545- JSON.stringify({546- v: 1,547- id: derived.record.id,548- ticket,549- ...(pop ? { pop: true } : {}),550- }),551- { headers: { "content-type": "application/json" } },588+ return respond(589+ new Response(590+ JSON.stringify({591+ v: 1,592+ id: derived.record.id,593+ ticket,594+ ...(pop ? { pop: true } : {}),595+ }),596+ { headers: { "content-type": "application/json" } },597+ ),552598 );553599 }554600 // Admin/catalogue-mutating routes need provision scope; a sync ticket555601 // gets the SAME 401 shape as an invalid ticket (nothing to enumerate).556602 if (ADMIN_PATH.test(strippedPath) && scope !== "provision") {557603 debug("rejected sync-scope on admin path", verdict.record.id);558- return unauthorized();604+ return respond(unauthorized());559605 }560606 const path = strippedPath + url.search;561607 debug(isUpgrade ? "ws" : req.method, path, "ticket", verdict.record.id, scope);562608 if (isUpgrade) {563609 const { response, socket } = proxyWebSocket(req, targetBase, path);564610 track(verdict.record.id, socket);565- return response;611+ return respond(response);566612 }567613 // The admin secret only ever originates from the node: inbound headers568614 // are stripped; provision-scoped requests get config's secret injected569615 // (on catalogue reads too — the merge flow fetches the head schema570616 // verbatim without ever holding the secret client-side).571- return proxyHttp(req, targetBase, path, {572- "x-jazz-admin-secret": scope === "provision" ? options.adminSecret : null,573- });617+ return respond(618+ await proxyHttp(req, targetBase, path, {619+ "x-jazz-admin-secret": scope === "provision" ? options.adminSecret : null,620+ }),621+ );574622 },575623 );576624 @@ -118,6 +118,46 @@ Deno.test({
118118 }),119119 });120120 121+Deno.test({122+ name: "gate: browser HTTP requests receive CORS preflight and response headers",123+ sanitizeOps: false,124+ sanitizeResources: false,125+ fn: () =>126+ withGate("ticket", async ({ gateUrl, store }) => {127+ const { secret } = await store.issue("browser", "provision");128+ const url = `${gateUrl}/t/${secret}/derive-sync-ticket`;129+ const preflight = await fetch(url, {130+ method: "OPTIONS",131+ headers: {132+ origin: "https://demo.lofi.host",133+ "access-control-request-method": "POST",134+ "access-control-request-headers": "content-type, x-client-version",135+ "access-control-request-private-network": "true",136+ },137+ });138+ assertEquals(preflight.status, 204);139+ assertEquals(preflight.headers.get("access-control-allow-origin"), "*");140+ assert(141+ preflight.headers.get("access-control-allow-methods")?.includes("POST"),142+ "preflight must allow POST",143+ );144+ assertEquals(145+ preflight.headers.get("access-control-allow-headers"),146+ "content-type, x-client-version",147+ );148+ assertEquals(preflight.headers.get("access-control-allow-private-network"), "true");149+150+ const response = await fetch(url, {151+ method: "POST",152+ headers: { origin: "https://demo.lofi.host", "content-type": "application/json" },153+ body: "{}",154+ });155+ assertEquals(response.status, 200);156+ assertEquals(response.headers.get("access-control-allow-origin"), "*");157+ await response.body?.cancel();158+ }),159+});160+121161 Deno.test({122162 name: "gate: WS round-trip with subprotocol echo and path stripping",123163 sanitizeOps: false,