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 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-21 23:15:52 +02:00
parent 0d00e003d4
commit 68151cbb5f
4 changed files with 42 additions and 12 deletions
+1 -5
View File
@@ -109,7 +109,6 @@ const atom = new Hono();
const entries = new Hono(); const entries = new Hono();
const files = new Hono(); const files = new Hono();
const admin = new Hono(); const admin = new Hono();
const hub = new Hono();
// Webhook security middleware for /inbound - verify ForwardEmail.net IP // Webhook security middleware for /inbound - verify ForwardEmail.net IP
api.use("/inbound", async (c, next) => { api.use("/inbound", async (c, next) => {
@@ -151,9 +150,6 @@ files.get("/:attachmentId/:filename", handleFiles);
// Admin routes (protected) // Admin routes (protected)
admin.route("/", handleAdmin); admin.route("/", handleAdmin);
// Hub (WebSub) routes
hub.route("/", hubRouter);
// Mount the route groups // Mount the route groups
app.route("/api", api); app.route("/api", api);
app.route("/rss", rss); app.route("/rss", rss);
@@ -161,7 +157,7 @@ app.route("/atom", atom);
app.route("/entries", entries); app.route("/entries", entries);
app.route("/files", files); app.route("/files", files);
app.route("/admin", admin); app.route("/admin", admin);
app.route("/hub", hub); app.route("/hub", hubRouter);
// Root path redirects to admin dashboard // Root path redirects to admin dashboard
app.get("/", (c) => c.redirect("/admin")); app.get("/", (c) => c.redirect("/admin"));
-6
View File
@@ -51,17 +51,11 @@ export async function handle(c: Context): Promise<Response> {
const baseUrl = `https://${env.DOMAIN}`; const baseUrl = `https://${env.DOMAIN}`;
const atomXml = generateAtomFeed(feedConfig, emailsData, baseUrl, feedId); const atomXml = generateAtomFeed(feedConfig, emailsData, baseUrl, feedId);
const linkHeader = [
`<https://${env.DOMAIN}/hub>; rel="hub"`,
`<https://${env.DOMAIN}/atom/${feedId}>; rel="self"`,
].join(", ");
return new Response(atomXml, { return new Response(atomXml, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "application/atom+xml", "Content-Type": "application/atom+xml",
"Cache-Control": "max-age=1800", "Cache-Control": "max-age=1800",
Link: linkHeader,
}, },
}); });
} catch (error) { } catch (error) {
+31
View File
@@ -127,6 +127,10 @@ describe("POST /hub — input validation", () => {
it("returns 400 when hub.secret exceeds 200 bytes", async () => { it("returns 400 when hub.secret exceeds 200 bytes", async () => {
const app = makeApp(); const app = makeApp();
const env = createMockEnv(); const env = createMockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({ title: "Feed 1" }),
);
const res = await app.request( const res = await app.request(
"/hub", "/hub",
hubBody({ hubBody({
@@ -145,6 +149,10 @@ describe("POST /hub — subscribe", () => {
it("returns 202 for valid subscribe request", async () => { it("returns 202 for valid subscribe request", async () => {
const app = makeApp(); const app = makeApp();
const env = createMockEnv(); const env = createMockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({ title: "Feed 1" }),
);
server.use( server.use(
http.get("https://cb.example/sub", ({ request }) => { http.get("https://cb.example/sub", ({ request }) => {
const challenge = const challenge =
@@ -167,6 +175,10 @@ describe("POST /hub — subscribe", () => {
it("accepts hub.lease_seconds within range", async () => { it("accepts hub.lease_seconds within range", async () => {
const app = makeApp(); const app = makeApp();
const env = createMockEnv(); const env = createMockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({ title: "Feed 1" }),
);
server.use( server.use(
http.get("https://cb.example/sub", ({ request }) => { http.get("https://cb.example/sub", ({ request }) => {
const challenge = const challenge =
@@ -186,12 +198,31 @@ describe("POST /hub — subscribe", () => {
); );
expect(res.status).toBe(202); 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", () => { describe("POST /hub — unsubscribe", () => {
it("returns 202 for valid unsubscribe request", async () => { it("returns 202 for valid unsubscribe request", async () => {
const app = makeApp(); const app = makeApp();
const env = createMockEnv(); const env = createMockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({ title: "Feed 1" }),
);
server.use( server.use(
http.get("https://cb.example/sub", ({ request }) => { http.get("https://cb.example/sub", ({ request }) => {
const challenge = const challenge =
+10 -1
View File
@@ -80,7 +80,16 @@ hubRouter.post("/", async (c) => {
} }
const feedId = match[1]; 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) { if (secret && secret.length > 200) {
return c.text("Bad Request: hub.secret must be under 200 bytes", 400); return c.text("Bad Request: hub.secret must be under 200 bytes", 400);
} }