diff --git a/router/index.ts b/router/index.ts index 9632cb11..93e1c362 100644 --- a/router/index.ts +++ b/router/index.ts @@ -505,6 +505,31 @@ function relayAgentOrigin(env: Env): string | undefined { return origin || undefined; } +// arelay.to carries agent chat, so it is HTTPS only. agentrelay.com gets this +// from the zone's Always Use HTTPS setting; arelay.to's zone does not have it, +// and keeping it here keeps it in source control. GET and HEAD get a permanent +// redirect. Anything else gets a 308 whose body names the HTTPS URL, so an +// agent's curl (which does not follow redirects) prints what to use instead, +// and the message is never processed over plain HTTP. +export function getInsecureShortHostResponse(url: URL, method: string): Response | undefined { + // A fully qualified Host ("arelay.to.") names the same site; match it too. + const hostname = url.hostname.replace(/\.$/, ""); + if (url.protocol !== "http:" || (hostname !== SHORT_HOST && hostname !== SHORT_HOST_WWW)) { + return undefined; + } + const secure = new URL(url); + secure.protocol = "https:"; + secure.hostname = hostname; + secure.port = ""; + if (method === "GET" || method === "HEAD") { + return Response.redirect(secure.toString(), 301); + } + return new Response(`agent-relay: use HTTPS: ${secure.toString()}\n`, { + status: 308, + headers: { location: secure.toString(), "content-type": "text/plain; charset=utf-8" }, + }); +} + class RelayAgentOriginError extends Error {} function validatedRelayAgentOrigin(origin: string): string { @@ -557,6 +582,11 @@ export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const url = new URL(request.url); + const insecureShortHost = getInsecureShortHostResponse(url, request.method); + if (insecureShortHost) { + return insecureShortHost; + } + const vanityRedirect = getVanityRedirect(url.hostname, url.pathname); if (vanityRedirect) { const redirectUrl = new URL(vanityRedirect); diff --git a/router/test/agent-chat.test.ts b/router/test/agent-chat.test.ts index 17841247..543a0918 100644 --- a/router/test/agent-chat.test.ts +++ b/router/test/agent-chat.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import worker, { + getInsecureShortHostResponse, getAgentChatCloudPath, getAgentPagePath, getLegacyAgentPageRedirect, @@ -337,3 +338,43 @@ describe("router agent chat", () => { expect(response.headers.get("location")).toBe("https://agentrelay.com/u/agent-relay"); }); }); + +describe("arelay.to over plain HTTP", () => { + it("redirects pages permanently to HTTPS, keeping the path and query", () => { + const response = getInsecureShortHostResponse(new URL("http://arelay.to/agent-relay?x=1"), "GET"); + expect(response?.status).toBe(301); + expect(response?.headers.get("location")).toBe("https://arelay.to/agent-relay?x=1"); + expect(getInsecureShortHostResponse(new URL("http://www.arelay.to/register"), "HEAD")?.status).toBe(301); + }); + + it("refuses chat over HTTP with a 308 that names the HTTPS URL", async () => { + const id = "0".repeat(32); + const response = getInsecureShortHostResponse(new URL(`http://arelay.to/agent-relay/${id}`), "POST"); + expect(response?.status).toBe(308); + expect(response?.headers.get("location")).toBe(`https://arelay.to/agent-relay/${id}`); + expect(await response?.text()).toBe(`agent-relay: use HTTPS: https://arelay.to/agent-relay/${id}\n`); + }); + + it("also catches a fully qualified host with a trailing dot", () => { + const response = getInsecureShortHostResponse(new URL("http://arelay.to./agent-relay"), "GET"); + expect(response?.status).toBe(301); + expect(response?.headers.get("location")).toBe("https://arelay.to/agent-relay"); + expect(getInsecureShortHostResponse(new URL(`http://www.arelay.to./agent-relay/${"0".repeat(32)}`), "POST")?.status).toBe(308); + }); + + it("leaves HTTPS and other hosts alone", () => { + expect(getInsecureShortHostResponse(new URL("https://arelay.to/agent-relay"), "GET")).toBeUndefined(); + expect(getInsecureShortHostResponse(new URL("http://agentrelay.com/u/agent-relay"), "GET")).toBeUndefined(); + }); + + it("never forwards an HTTP chat message upstream", async () => { + const fetchMock = vi.fn(); + const response = await worker.fetch( + new Request(`http://arelay.to/agent-relay/${"0".repeat(32)}`, { method: "POST", body: "hi" }), + buildEnv({ fetch: fetchMock }), + ctx, + ); + expect(response.status).toBe(308); + expect(fetchMock).not.toHaveBeenCalled(); + }); +});