Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
khaliqgant marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -557,6 +582,11 @@ export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
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);
Expand Down
41 changes: 41 additions & 0 deletions router/test/agent-chat.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import worker, {
getInsecureShortHostResponse,
getAgentChatCloudPath,
getAgentPagePath,
getLegacyAgentPageRedirect,
Expand Down Expand Up @@ -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();
});
});
Loading