mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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:
+1
-5
@@ -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"));
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user