diff --git a/.github/workflows/deploy-router.yml b/.github/workflows/deploy-router.yml index 0325c584..3d3ecec0 100644 --- a/.github/workflows/deploy-router.yml +++ b/.github/workflows/deploy-router.yml @@ -88,11 +88,14 @@ jobs: # Worker secrets ship atomically with the deploy. Only secrets that are # present are written; an absent ANALYTICS_VISITOR_HASH_SECRET leaves - # guide-fetch analytics failing closed (no data points). + # guide-fetch analytics failing closed (no data points), and an absent + # OPENAI_APPS_CHALLENGE_TOKEN leaves /.well-known/openai-apps-challenge + # returning 404. - name: Prepare optional Worker secrets shell: bash env: ANALYTICS_VISITOR_HASH_SECRET: ${{ secrets.ANALYTICS_VISITOR_HASH_SECRET }} + OPENAI_APPS_CHALLENGE_TOKEN: ${{ secrets.OPENAI_APPS_CHALLENGE_TOKEN }} run: | set -euo pipefail umask 077 @@ -100,7 +103,7 @@ jobs: node --input-type=module <<'NODE' import { writeFileSync } from "node:fs"; - const names = ["ANALYTICS_VISITOR_HASH_SECRET"]; + const names = ["ANALYTICS_VISITOR_HASH_SECRET", "OPENAI_APPS_CHALLENGE_TOKEN"]; const secrets = Object.fromEntries(names.flatMap((name) => { const value = process.env[name]; return value ? [[name, value]] : []; diff --git a/router/index.ts b/router/index.ts index 7d97541a..83f9ace6 100644 --- a/router/index.ts +++ b/router/index.ts @@ -18,6 +18,10 @@ interface Env extends AnalyticsEnv { fetch(request: Request): Promise; }; WEBHOOK_WORKER_ORIGIN?: string; + // Worker secret holding OpenAI's apps domain-verification token. Shipped by + // deploy-router.yml from the OPENAI_APPS_CHALLENGE_TOKEN GitHub Actions + // secret; never committed. Unset means the challenge path 404s. + OPENAI_APPS_CHALLENGE_TOKEN?: string; } function hasRecorderEnv(env: Env): env is Env & RecorderEnv { @@ -688,10 +692,54 @@ async function fetchRelayAgent(request: Request, url: URL, env: Env): Promise, +): Response | null { + if (url.hostname !== PRIMARY_HOST || url.pathname !== OPENAI_APPS_CHALLENGE_PATH) { + return null; + } + + const headers = { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + }; + if (request.method !== "GET" && request.method !== "HEAD") { + return new Response("Method Not Allowed", { + status: 405, + headers: { ...headers, allow: "GET, HEAD" }, + }); + } + + const token = env.OPENAI_APPS_CHALLENGE_TOKEN?.trim(); + if (!token) { + return new Response("Not Found", { status: 404, headers }); + } + + return new Response(request.method === "HEAD" ? null : token, { + status: 200, + headers, + }); +} + const router = { async fetch(request: Request, env: Env, ctx: ExecutionContext, timing: EdgeTiming): Promise { const url = new URL(request.url); + const openAiAppsChallenge = getOpenAiAppsChallengeResponse(request, url, env); + if (openAiAppsChallenge) { + return openAiAppsChallenge; + } + const oauthWellKnownPath = url.hostname === PRIMARY_HOST && (request.method === "GET" || request.method === "HEAD") ? rewriteCloudOAuthWellKnownPath(url.pathname) diff --git a/router/test/openai-apps-challenge.test.ts b/router/test/openai-apps-challenge.test.ts new file mode 100644 index 00000000..654dfe6c --- /dev/null +++ b/router/test/openai-apps-challenge.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; + +import worker from "../index.js"; + +const TOKEN = "test-challenge-token-not-real"; + +function buildEnv(overrides: Record = {}) { + return { + CLOUD_APP_ORIGIN: "https://origin.test.invalid", + CLOUD_WEB_WORKER: { fetch: vi.fn(async () => new Response("cloud", { status: 200 })) }, + ...overrides, + } as unknown as Parameters[1]; +} + +const ctx = { waitUntil: () => undefined, passThroughOnException: () => undefined } as unknown as ExecutionContext; + +const CHALLENGE_URL = "https://agentrelay.com/.well-known/openai-apps-challenge"; + +describe("OpenAI apps domain challenge", () => { + it("returns only the token as text/plain with a 200 when the secret is set", async () => { + const env = buildEnv({ OPENAI_APPS_CHALLENGE_TOKEN: TOKEN }); + const response = await worker.fetch(new Request(CHALLENGE_URL), env, ctx); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + expect(response.headers.get("location")).toBeNull(); + expect(await response.text()).toBe(TOKEN); + expect((env as unknown as { CLOUD_WEB_WORKER: { fetch: ReturnType } }) + .CLOUD_WEB_WORKER.fetch).not.toHaveBeenCalled(); + }); + + it("trims surrounding whitespace from the secret value", async () => { + const response = await worker.fetch( + new Request(CHALLENGE_URL), + buildEnv({ OPENAI_APPS_CHALLENGE_TOKEN: ` ${TOKEN}\n` }), + ctx, + ); + expect(await response.text()).toBe(TOKEN); + }); + + it("answers HEAD with a 200 and no body", async () => { + const response = await worker.fetch( + new Request(CHALLENGE_URL, { method: "HEAD" }), + buildEnv({ OPENAI_APPS_CHALLENGE_TOKEN: TOKEN }), + ctx, + ); + expect(response.status).toBe(200); + expect(await response.text()).toBe(""); + }); + + it.each([undefined, "", " "])("returns 404 when the secret is %j", async (value) => { + const response = await worker.fetch( + new Request(CHALLENGE_URL), + buildEnv({ OPENAI_APPS_CHALLENGE_TOKEN: value }), + ctx, + ); + expect(response.status).toBe(404); + expect(await response.text()).not.toContain(TOKEN); + }); + + it("rejects non-GET methods without leaking the token", async () => { + const response = await worker.fetch( + new Request(CHALLENGE_URL, { method: "POST" }), + buildEnv({ OPENAI_APPS_CHALLENGE_TOKEN: TOKEN }), + ctx, + ); + expect(response.status).toBe(405); + expect(await response.text()).not.toContain(TOKEN); + }); + + it("only answers on the agentrelay.com apex", async () => { + const upstream = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("upstream", { status: 404 })); + const response = await worker.fetch( + new Request("https://arelay.to/.well-known/openai-apps-challenge"), + buildEnv({ OPENAI_APPS_CHALLENGE_TOKEN: TOKEN }), + ctx, + ); + expect(await response.text()).not.toBe(TOKEN); + upstream.mockRestore(); + }); + + it("does not serve the token on neighbouring paths", async () => { + const upstream = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("upstream", { status: 404 })); + for (const path of [ + "/.well-known/openai-apps-challenge/", + "/.well-known/openai-apps-challenge/x", + "/cloud/.well-known/openai-apps-challenge", + ]) { + const response = await worker.fetch( + new Request(`https://agentrelay.com${path}`), + buildEnv({ OPENAI_APPS_CHALLENGE_TOKEN: TOKEN }), + ctx, + ); + expect(await response.text()).not.toBe(TOKEN); + } + upstream.mockRestore(); + }); +});