From 68151cbb5f9ab881bb8a06a62f677fc4ae425259 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Thu, 21 May 2026 23:15:52 +0200 Subject: [PATCH] fix(websub): require feed existence for subscriptions, remove atom hub header, simplify router mounting - Add KV feed existence check in hub.ts to prevent SSRF via non-existent feeds (returns 404) - Treat empty string hub.secret as absent (|| instead of ??) - Remove misleading hub Link header from atom.ts (hub only supports RSS topics) - Simplify double-layered hub router in index.ts (direct app.route instead of nested Hono) - Update hub.test.ts to seed KV with feed config for tests requiring valid subscribe/unsubscribe Co-Authored-By: Claude Sonnet 4.6 --- src/index.ts | 6 +----- src/routes/atom.ts | 6 ------ src/routes/hub.test.ts | 31 +++++++++++++++++++++++++++++++ src/routes/hub.ts | 11 ++++++++++- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6426ea5..c574cf0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -109,7 +109,6 @@ const atom = new Hono(); const entries = new Hono(); const files = new Hono(); const admin = new Hono(); -const hub = new Hono(); // Webhook security middleware for /inbound - verify ForwardEmail.net IP api.use("/inbound", async (c, next) => { @@ -151,9 +150,6 @@ files.get("/:attachmentId/:filename", handleFiles); // Admin routes (protected) admin.route("/", handleAdmin); -// Hub (WebSub) routes -hub.route("/", hubRouter); - // Mount the route groups app.route("/api", api); app.route("/rss", rss); @@ -161,7 +157,7 @@ app.route("/atom", atom); app.route("/entries", entries); app.route("/files", files); app.route("/admin", admin); -app.route("/hub", hub); +app.route("/hub", hubRouter); // Root path redirects to admin dashboard app.get("/", (c) => c.redirect("/admin")); diff --git a/src/routes/atom.ts b/src/routes/atom.ts index 22479e2..c05bea5 100644 --- a/src/routes/atom.ts +++ b/src/routes/atom.ts @@ -51,17 +51,11 @@ export async function handle(c: Context): Promise { const baseUrl = `https://${env.DOMAIN}`; const atomXml = generateAtomFeed(feedConfig, emailsData, baseUrl, feedId); - const linkHeader = [ - `; rel="hub"`, - `; rel="self"`, - ].join(", "); - return new Response(atomXml, { status: 200, headers: { "Content-Type": "application/atom+xml", "Cache-Control": "max-age=1800", - Link: linkHeader, }, }); } catch (error) { diff --git a/src/routes/hub.test.ts b/src/routes/hub.test.ts index af7bacf..2cc3855 100644 --- a/src/routes/hub.test.ts +++ b/src/routes/hub.test.ts @@ -127,6 +127,10 @@ describe("POST /hub — input validation", () => { it("returns 400 when hub.secret exceeds 200 bytes", async () => { const app = makeApp(); const env = createMockEnv(); + await env.EMAIL_STORAGE.put( + "feed:feed1:config", + JSON.stringify({ title: "Feed 1" }), + ); const res = await app.request( "/hub", hubBody({ @@ -145,6 +149,10 @@ describe("POST /hub — subscribe", () => { it("returns 202 for valid subscribe request", async () => { const app = makeApp(); const env = createMockEnv(); + await env.EMAIL_STORAGE.put( + "feed:feed1:config", + JSON.stringify({ title: "Feed 1" }), + ); server.use( http.get("https://cb.example/sub", ({ request }) => { const challenge = @@ -167,6 +175,10 @@ describe("POST /hub — subscribe", () => { it("accepts hub.lease_seconds within range", async () => { const app = makeApp(); const env = createMockEnv(); + await env.EMAIL_STORAGE.put( + "feed:feed1:config", + JSON.stringify({ title: "Feed 1" }), + ); server.use( http.get("https://cb.example/sub", ({ request }) => { const challenge = @@ -186,12 +198,31 @@ describe("POST /hub — subscribe", () => { ); expect(res.status).toBe(202); }); + + it("returns 404 when feed does not exist", async () => { + const app = makeApp(); + const env = createMockEnv(); + const res = await app.request( + "/hub", + hubBody({ + "hub.mode": "subscribe", + "hub.topic": `https://${env.DOMAIN}/rss/nonexistent`, + "hub.callback": "https://cb.example/sub", + }), + env, + ); + expect(res.status).toBe(404); + }); }); describe("POST /hub — unsubscribe", () => { it("returns 202 for valid unsubscribe request", async () => { const app = makeApp(); const env = createMockEnv(); + await env.EMAIL_STORAGE.put( + "feed:feed1:config", + JSON.stringify({ title: "Feed 1" }), + ); server.use( http.get("https://cb.example/sub", ({ request }) => { const challenge = diff --git a/src/routes/hub.ts b/src/routes/hub.ts index b470088..fae6ce8 100644 --- a/src/routes/hub.ts +++ b/src/routes/hub.ts @@ -80,7 +80,16 @@ hubRouter.post("/", async (c) => { } const feedId = match[1]; - const secret = form.get("hub.secret") ?? undefined; + // Verify the feed exists before accepting any subscription + const feedConfig = await env.EMAIL_STORAGE.get( + `feed:${feedId}:config`, + "json", + ); + if (!feedConfig) { + return c.text("Not Found: feed does not exist", 404); + } + + const secret = form.get("hub.secret") || undefined; // "" → undefined if (secret && secret.length > 200) { return c.text("Bad Request: hub.secret must be under 200 bytes", 400); }