From 8fec277279f74c424f9eb73fa2198aab29ca17b6 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 7 Oct 2026 19:49:02 -0700 Subject: [PATCH 1/2] fix(router): arelay.to is HTTPS only http://arelay.to served pages and accepted chat messages over plain HTTP. GET/HEAD now redirect permanently to HTTPS; other methods get a 308 whose body names the HTTPS URL (curl does not follow redirects, so the agent sees what to use) and the message is never processed or forwarded. Co-Authored-By: Claude Opus 5.5 (1M context) --- router/index.ts | 27 +++++++++++++++++++++++++++ router/test/agent-chat.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/router/index.ts b/router/index.ts index 9632cb11..f63db924 100644 --- a/router/index.ts +++ b/router/index.ts @@ -505,6 +505,28 @@ 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 { + if (url.protocol !== "http:" || (url.hostname !== SHORT_HOST && url.hostname !== SHORT_HOST_WWW)) { + return undefined; + } + const secure = new URL(url); + secure.protocol = "https:"; + 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 +579,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..d15e031f 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,36 @@ 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("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(); + }); +}); From 74026a4ee228f55d025d15165518bb810f775e8e Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 7 Oct 2026 20:05:48 -0700 Subject: [PATCH 2/2] fix(router): treat arelay.to. (trailing dot) as arelay.to in the HTTPS gate Co-Authored-By: Claude Opus 5.5 (1M context) --- router/index.ts | 5 ++++- router/test/agent-chat.test.ts | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/router/index.ts b/router/index.ts index f63db924..93e1c362 100644 --- a/router/index.ts +++ b/router/index.ts @@ -512,11 +512,14 @@ function relayAgentOrigin(env: Env): string | undefined { // 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 { - if (url.protocol !== "http:" || (url.hostname !== SHORT_HOST && url.hostname !== SHORT_HOST_WWW)) { + // 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); diff --git a/router/test/agent-chat.test.ts b/router/test/agent-chat.test.ts index d15e031f..543a0918 100644 --- a/router/test/agent-chat.test.ts +++ b/router/test/agent-chat.test.ts @@ -355,6 +355,13 @@ describe("arelay.to over plain HTTP", () => { 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();