From d5274b759d0576e9ec7a02964302ec7e9dded092 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Thu, 30 Jul 2026 13:22:18 +0200 Subject: [PATCH] add(websub): ship a self-hub for this site's two feeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the site a worked example of /spec/foundations/websub/ rather than a page describing something we do not run. A *self-hub*: hub.topic is allowlisted to /rss.xml and /changelog/rss.xml, so the endpoint cannot fetch arbitrary URLs or relay anyone else's content. That constraint is what makes a hub reasonable to run here — an open hub is a public service with a very different risk profile. - functions/websub.ts — subscribe/unsubscribe (WebSub 5.1), verification of intent (5.3), content distribution with Link rel=hub/self and an X-Hub-Signature HMAC-SHA256 (5.4), plus a bearer-gated hub.mode=publish (the PubSubHubbub 0.4 convention; WebSub 4 leaves this out of scope). - migrations/0001 — D1 subscription store. Leases expire lazily at distribution time, so the hub needs no cron trigger. - .github/workflows/websub-ping.yml — pings the hub after a content push, mirroring deploy-mcp.yml's trigger. Waits for the Pages deploy first; distributing a stale feed is worse than distributing late. - scripts/test-websub.mjs (npm run test:websub) — 35 assertions over the callback guard, lease clamping, constant-time token compare, and the HMAC against the RFC 4231 known vector. - privacy.astro — subscribing is the first thing on this site that stores data a visitor gives us. Says what is kept, for how long, and who sees it. The D1 binding is committed COMMENTED OUT and the hub 503s without it, so this is inert and safe to merge before the database exists. The rel="hub" advertisement is held back to the same commit that provisions D1 — shipping a hub link with nothing behind it is the exact failure the spec page warns about. wrangler.toml carries the go-live checklist. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/websub-ping.yml | 81 ++++ functions/websub.ts | 471 +++++++++++++++++++++++ migrations/0001_websub_subscriptions.sql | 34 ++ package.json | 1 + public/_headers | 18 + scripts/test-websub.mjs | 100 +++++ src/pages/changelog/rss.xml.ts | 3 + src/pages/privacy.astro | 39 +- src/pages/rss.xml.ts | 3 + wrangler.toml | 44 +++ 10 files changed, 793 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/websub-ping.yml create mode 100644 functions/websub.ts create mode 100644 migrations/0001_websub_subscriptions.sql create mode 100644 scripts/test-websub.mjs diff --git a/.github/workflows/websub-ping.yml b/.github/workflows/websub-ping.yml new file mode 100644 index 00000000..4db5647b --- /dev/null +++ b/.github/workflows/websub-ping.yml @@ -0,0 +1,81 @@ +name: WebSub ping + +# Notify our own WebSub hub that a feed changed, so subscribers are pushed the +# update instead of waiting for their next poll. +# +# WebSub §4 leaves publisher→hub notification out of scope, so this uses the +# de-facto PubSubHubbub 0.4 convention (hub.mode=publish) against +# functions/websub.ts, authenticated with a bearer token. +# +# Trigger mirrors deploy-mcp.yml: content commits are what change the feeds. +# - src/content/spec/** → /rss.xml +# - src/content/changelog/** → /changelog/rss.xml +# Both topics are pinged on either path. Distinguishing them would save one +# no-op request and cost a conditional; the hub short-circuits with 202 "no +# active subscribers" anyway. +on: + push: + branches: [main] + paths: + - "src/content/spec/**" + - "src/content/changelog/**" + - ".github/workflows/websub-ping.yml" + # Manual trigger for testing the hub without a content commit. + workflow_dispatch: + +# The ping must land *after* Cloudflare Pages has published the new feed, +# otherwise the hub reads and distributes the previous build. Pages deploys on +# its own schedule, so wait before pinging (see the sleep below) and never run +# two pings concurrently. +concurrency: + group: websub-ping + cancel-in-progress: false + +permissions: + contents: read + +jobs: + ping: + runs-on: ubuntu-latest + # Skip entirely on forks, where the secret is absent. + if: github.repository == 'jdevalk/specification.website' + steps: + # Pages typically finishes a build in 1-3 minutes. Distributing a stale + # feed is worse than distributing late — subscribers would get the old + # body and, having been notified, would not re-poll. + - name: Wait for the Pages deploy to go live + run: sleep 300 + + - name: Ping the hub for each topic + env: + SECRET: ${{ secrets.WEBSUB_PUBLISH_SECRET }} + run: | + set -euo pipefail + if [ -z "${SECRET:-}" ]; then + echo "WEBSUB_PUBLISH_SECRET is not set — skipping." + exit 0 + fi + for topic in \ + "https://specification.website/rss.xml" \ + "https://specification.website/changelog/rss.xml" + do + echo "::group::$topic" + code=$(curl -sS -o /tmp/body.txt -w '%{http_code}' \ + -X POST "https://specification.website/websub" \ + -H "Authorization: Bearer $SECRET" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "hub.mode=publish" \ + --data-urlencode "hub.topic=$topic" \ + --retry 3 --retry-delay 10 --retry-connrefused \ + --max-time 30) + cat /tmp/body.txt + echo "HTTP $code" + echo "::endgroup::" + # 202 is the only success. Anything else fails the job loudly: a + # silently broken hub is exactly the failure mode the spec page + # warns about. + if [ "$code" != "202" ]; then + echo "::error::Hub returned $code for $topic" + exit 1 + fi + done diff --git a/functions/websub.ts b/functions/websub.ts new file mode 100644 index 00000000..5c8e8c81 --- /dev/null +++ b/functions/websub.ts @@ -0,0 +1,471 @@ +/** + * WebSub hub — the push side of /spec/foundations/websub/. + * + * A *self-hub*: it distributes this site's own two feeds and nothing else. + * `hub.topic` is checked against ALLOWED_TOPICS below, so the endpoint cannot + * be used to fetch arbitrary URLs or to relay someone else's content. That + * single constraint is what makes running a hub on a static site reasonable — + * an open hub is a public service with a very different risk profile. + * + * Implements WebSub (W3C Recommendation, 2 June 2026): + * - §5.1 subscribe / unsubscribe → POST hub.mode=subscribe|unsubscribe + * - §5.3 verification of intent → async GET to hub.callback, echo challenge + * - §5.4 content distribution → POST body + Link rel=hub/self + signature + * + * Publishing is deliberately *not* part of WebSub: §4 leaves "how the publisher + * notifies the hub" out of scope. We accept the de-facto PubSubHubbub 0.4 + * convention (`hub.mode=publish`), gated behind a bearer token, because only + * this site's own deploy may trigger a fan-out. The GitHub Action in + * .github/workflows/websub-ping.yml is the only caller. + * + * Outbound-request safety. This is the only code in the repo that makes + * requests to URLs supplied by strangers, so: + * - callbacks must be https, with a real hostname (never an IP literal, never + * a single-label or .internal/.local name) — see isSafeCallback; + * - nothing is ever delivered to a callback that has not completed the + * challenge handshake, which is the protocol's own anti-amplification + * measure; + * - subscriber count per topic and lease length are both capped; + * - distribution only fires on a real publish (a few times a week), never on + * demand from an unauthenticated caller. + * + * Degrades to 503 when the D1 binding is absent, so the site deploys and + * behaves normally before the database has been provisioned. Like the rest of + * functions/, it never throws out of the handler. + */ + +type Env = { + ASSETS: Fetcher; + /** D1 binding. Absent until the database is provisioned — see wrangler.toml. */ + WEBSUB_DB?: D1Database; + /** Bearer token authorising hub.mode=publish. Pages secret. */ + WEBSUB_PUBLISH_SECRET?: string; +}; + +interface Subscription { + callback: string; + secret: string | null; +} + +const HUB_URL = "https://specification.website/websub"; + +/** + * The only topics this hub will serve. Absolute, canonical, and matching the + * `rel="self"` we advertise on each feed — subscribers key their subscription + * on this exact string, so it must not drift from public/_headers or from the + * `atom:link rel="self"` inside each feed. + */ +const ALLOWED_TOPICS: Record = { + "https://specification.website/rss.xml": "/rss.xml", + "https://specification.website/changelog/rss.xml": "/changelog/rss.xml", +}; + +const DEFAULT_LEASE_SECONDS = 864000; // 10 days — comfortably longer than our publish cadence +const MIN_LEASE_SECONDS = 3600; +const MAX_LEASE_SECONDS = 2592000; // 30 days +const MAX_SECRET_BYTES = 200; // WebSub §5.1: MUST be less than 200 bytes +const MAX_SUBSCRIBERS_PER_TOPIC = 500; +const VERIFY_TIMEOUT_MS = 10_000; +const DELIVER_TIMEOUT_MS = 15_000; +const DELIVER_CONCURRENCY = 10; + +// ---------------------------------------------------------------- helpers + +function text(body: string, status: number, extra?: HeadersInit): Response { + return new Response(body, { + status, + headers: { "Content-Type": "text/plain; charset=utf-8", ...extra }, + }); +} + +function now(): number { + return Math.floor(Date.now() / 1000); +} + +function hex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Constant-time string comparison for the publish token. `===` on secrets leaks + * a prefix-length oracle through timing; this always inspects every byte of the + * longer input. + */ +export function timingSafeEqual(a: string, b: string): boolean { + const enc = new TextEncoder(); + const ab = enc.encode(a); + const bb = enc.encode(b); + let diff = ab.length ^ bb.length; + const len = Math.max(ab.length, bb.length); + for (let i = 0; i < len; i++) diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0); + return diff === 0; +} + +/** HMAC per WebSub §5.4. sha256 is the floor; sha1 is legacy and never emitted. */ +export async function signPayload( + secret: string, + body: ArrayBuffer, +): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return hex(await crypto.subtle.sign("HMAC", key, body)); +} + +/** + * Is this callback URL safe to send requests to? + * + * Rejecting every IP literal (rather than enumerating RFC 1918, loopback, + * link-local, CGNAT, and their IPv6 equivalents) kills the whole SSRF class in + * one rule and costs legitimate subscribers nothing — a feed reader always has + * a hostname. Single-label names and .internal/.local are refused for the same + * reason. Non-443 ports are refused so the hub cannot be pointed at an + * arbitrary service on a host that happens to resolve publicly. + */ +export function isSafeCallback(raw: string): boolean { + let url: URL; + try { + url = new URL(raw); + } catch { + return false; + } + if (url.protocol !== "https:") return false; + if (url.username || url.password) return false; + if (url.port && url.port !== "443") return false; + + const host = url.hostname.toLowerCase().replace(/\.$/, ""); + if (!host) return false; + // IPv6 literals arrive bracketed; URL strips the brackets but keeps the colons. + if (host.includes(":")) return false; + // IPv4 literal, in whole or in part (also catches 0x7f.1, 2130706433, …). + if (/^[\d.]+$/.test(host)) return false; + // Needs a real public-looking name: at least one dot, no internal suffixes. + if (!host.includes(".")) return false; + if (/\.(internal|local|localhost|home\.arpa)$/.test(host)) return false; + return true; +} + +export function clampLease(raw: string | null): number { + const n = Number.parseInt(raw ?? "", 10); + if (!Number.isFinite(n) || n <= 0) return DEFAULT_LEASE_SECONDS; + return Math.min(Math.max(n, MIN_LEASE_SECONDS), MAX_LEASE_SECONDS); +} + +// ------------------------------------------------- verification of intent + +/** + * WebSub §5.3. GET the callback with the challenge and confirm the subscriber + * echoes it back with a 2xx. Only then does the subscription take effect, which + * is what stops anyone subscribing a callback they do not control. + * + * Runs in waitUntil() after the 202 has already been sent, so it must swallow + * every failure: a rejected or unreachable callback simply means no + * subscription, which is the correct outcome. + */ +async function verifyAndApply( + db: D1Database, + mode: "subscribe" | "unsubscribe", + topic: string, + callback: string, + secret: string | null, + leaseSeconds: number, +): Promise { + try { + const challenge = crypto.randomUUID(); + const probe = new URL(callback); + probe.searchParams.set("hub.mode", mode); + probe.searchParams.set("hub.topic", topic); + probe.searchParams.set("hub.challenge", challenge); + if (mode === "subscribe") { + probe.searchParams.set("hub.lease_seconds", String(leaseSeconds)); + } + + const res = await fetch(probe.toString(), { + method: "GET", + redirect: "error", // a redirected challenge is not a confirmation + signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS), + headers: { "User-Agent": "specification.website-websub-hub/1.0" }, + }); + if (!res.ok) return; + // Compare trimmed: the spec wants the body *equal* to the challenge, but + // trailing newlines from naive handlers are near-universal and harmless. + if ((await res.text()).trim() !== challenge) return; + + if (mode === "subscribe") { + await db + .prepare( + `INSERT INTO subscriptions (topic, callback, secret, lease_expires_at, created_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT (topic, callback) DO UPDATE SET + secret = excluded.secret, + lease_expires_at = excluded.lease_expires_at`, + ) + .bind(topic, callback, secret, now() + leaseSeconds, now()) + .run(); + } else { + await db + .prepare(`DELETE FROM subscriptions WHERE topic = ?1 AND callback = ?2`) + .bind(topic, callback) + .run(); + } + } catch { + // Unreachable callback, DNS failure, timeout, D1 hiccup — all mean + // "no change", and there is nobody left to report it to. + } +} + +// ---------------------------------------------------- content distribution + +/** + * WebSub §5.4. POST the full topic content to one subscriber, with the two + * required Link relations and — when the subscriber supplied a secret — an + * HMAC over exactly the bytes being sent. + */ +async function deliver( + sub: Subscription, + topic: string, + body: ArrayBuffer, + contentType: string, +): Promise { + try { + const headers = new Headers({ + "Content-Type": contentType, + "User-Agent": "specification.website-websub-hub/1.0", + }); + headers.append("Link", `<${HUB_URL}>; rel="hub"`); + headers.append("Link", `<${topic}>; rel="self"`); + if (sub.secret) { + headers.set( + "X-Hub-Signature", + `sha256=${await signPayload(sub.secret, body)}`, + ); + } + await fetch(sub.callback, { + method: "POST", + headers, + body, + signal: AbortSignal.timeout(DELIVER_TIMEOUT_MS), + }); + } catch { + // A subscriber that is down misses this notification. WebSub allows a hub + // to retry; we deliberately do not, because the next publish carries the + // full current feed anyway and a retry queue would need durable state. + } +} + +/** Fan out in bounded batches so a large subscriber list cannot exhaust subrequests. */ +async function deliverAll( + subs: Subscription[], + topic: string, + body: ArrayBuffer, + contentType: string, +): Promise { + for (let i = 0; i < subs.length; i += DELIVER_CONCURRENCY) { + await Promise.allSettled( + subs + .slice(i, i + DELIVER_CONCURRENCY) + .map((s) => deliver(s, topic, body, contentType)), + ); + } +} + +// ------------------------------------------------------------- handlers + +/** + * `rel="hub"` should be dereferenceable — a bare 405 on GET tells a curious + * subscriber nothing. Describes the endpoint and names the topics it serves. + */ +export const onRequestGet: PagesFunction = async () => { + const body = + "WebSub hub for specification.website\n" + + "====================================\n\n" + + "This is a self-hub: it distributes only this site's own feeds.\n\n" + + "Spec: https://www.w3.org/TR/websub/\n" + + "Docs: https://specification.website/spec/foundations/websub/\n\n" + + "Topics served:\n" + + Object.keys(ALLOWED_TOPICS) + .map((t) => ` ${t}\n`) + .join("") + + "\nTo subscribe, POST application/x-www-form-urlencoded to this URL with\n" + + "hub.mode=subscribe, hub.topic (one of the above), hub.callback, and\n" + + "optionally hub.secret and hub.lease_seconds. You will receive a 202, then\n" + + "a GET to your callback carrying hub.challenge, which you must echo.\n\n" + + `Leases: default ${DEFAULT_LEASE_SECONDS}s, maximum ${MAX_LEASE_SECONDS}s. Re-subscribe to renew.\n` + + "Callbacks must be https with a resolvable hostname.\n"; + return text(body, 200, { "Cache-Control": "public, max-age=3600" }); +}; + +export const onRequestPost: PagesFunction = async (context) => { + const { request, env, waitUntil } = context; + + const db = env.WEBSUB_DB; + if (!db) { + return text( + "WebSub hub is not provisioned yet (no database binding).\n", + 503, + { "Retry-After": "3600" }, + ); + } + + let form: URLSearchParams; + try { + form = new URLSearchParams(await request.text()); + } catch { + return text("Malformed request body.\n", 400); + } + + const mode = (form.get("hub.mode") ?? "").toLowerCase(); + // PubSubHubbub 0.4 publishers send the topic as hub.url; accept both. + const topic = form.get("hub.topic") ?? form.get("hub.url") ?? ""; + + if (!Object.prototype.hasOwnProperty.call(ALLOWED_TOPICS, topic)) { + return text( + "Unknown hub.topic. This hub serves only:\n" + + Object.keys(ALLOWED_TOPICS) + .map((t) => ` ${t}\n`) + .join(""), + 404, + ); + } + + if (mode === "publish") return handlePublish(context, db, topic); + if (mode !== "subscribe" && mode !== "unsubscribe") { + return text("hub.mode must be subscribe, unsubscribe, or publish.\n", 400); + } + + const callback = form.get("hub.callback") ?? ""; + if (!isSafeCallback(callback)) { + return text( + "hub.callback must be an https URL with a resolvable public hostname " + + "(no IP literals, no non-443 ports, no credentials).\n", + 400, + ); + } + + const secret = form.get("hub.secret"); + if (secret !== null) { + if (new TextEncoder().encode(secret).length >= MAX_SECRET_BYTES) { + return text("hub.secret must be less than 200 bytes.\n", 400); + } + if (secret.length === 0) { + return text("hub.secret, if given, must not be empty.\n", 400); + } + } + + const leaseSeconds = clampLease(form.get("hub.lease_seconds")); + + // Cap the subscriber list. Checked before the handshake so an attacker cannot + // spend our subrequest budget once the table is full. An existing subscriber + // renewing is always allowed through, since that is an update, not growth. + if (mode === "subscribe") { + try { + const existing = await db + .prepare( + `SELECT 1 AS hit FROM subscriptions WHERE topic = ?1 AND callback = ?2`, + ) + .bind(topic, callback) + .first<{ hit: number }>(); + if (!existing) { + const count = await db + .prepare( + `SELECT COUNT(*) AS n FROM subscriptions + WHERE topic = ?1 AND lease_expires_at > ?2`, + ) + .bind(topic, now()) + .first<{ n: number }>(); + if ((count?.n ?? 0) >= MAX_SUBSCRIBERS_PER_TOPIC) { + return text( + "This hub is at its subscriber limit for that topic.\n", + 503, + { "Retry-After": "86400" }, + ); + } + } + } catch { + return text("Subscription store unavailable.\n", 503, { + "Retry-After": "300", + }); + } + } + + // WebSub §5.1: acknowledge with 202 and verify out of band. + waitUntil(verifyAndApply(db, mode, topic, callback, secret, leaseSeconds)); + return text( + `Accepted. Awaiting verification of intent at your callback.\n`, + 202, + ); +}; + +/** + * Trigger a fan-out for one topic. Authenticated: only this site's deploy may + * cause the hub to make outbound requests on demand. + */ +async function handlePublish( + context: Parameters>[0], + db: D1Database, + topic: string, +): Promise { + const { request, env, waitUntil } = context; + + const expected = env.WEBSUB_PUBLISH_SECRET; + if (!expected) { + return text("Publishing is not configured on this hub.\n", 503); + } + const offered = (request.headers.get("authorization") ?? "").replace( + /^Bearer\s+/i, + "", + ); + if (!offered || !timingSafeEqual(offered, expected)) { + return text("Unauthorised.\n", 401, { + "WWW-Authenticate": 'Bearer realm="websub-publish"', + }); + } + + try { + // Lazy lease enforcement — no cron trigger needed. + await db + .prepare(`DELETE FROM subscriptions WHERE lease_expires_at <= ?1`) + .bind(now()) + .run(); + + const { results } = await db + .prepare( + `SELECT callback, secret FROM subscriptions + WHERE topic = ?1 AND lease_expires_at > ?2`, + ) + .bind(topic, now()) + .all(); + const subs = results ?? []; + + if (subs.length === 0) { + return text("No active subscribers for that topic.\n", 202); + } + + // Read the topic through the asset binding rather than over the network: + // both feeds are static files in dist, so this is a local read and cannot + // loop back through the edge. + const path = ALLOWED_TOPICS[topic]; + const upstream = await env.ASSETS.fetch( + new URL(path, request.url).toString(), + ); + if (!upstream.ok) { + return text(`Could not read topic ${path}.\n`, 502); + } + const body = await upstream.arrayBuffer(); + const contentType = + upstream.headers.get("content-type") ?? "application/rss+xml"; + + waitUntil(deliverAll(subs, topic, body, contentType)); + return text(`Distributing to ${subs.length} subscriber(s).\n`, 202); + } catch { + return text("Subscription store unavailable.\n", 503, { + "Retry-After": "300", + }); + } +} diff --git a/migrations/0001_websub_subscriptions.sql b/migrations/0001_websub_subscriptions.sql new file mode 100644 index 00000000..a10444eb --- /dev/null +++ b/migrations/0001_websub_subscriptions.sql @@ -0,0 +1,34 @@ +-- WebSub hub — subscription store for functions/websub.ts. +-- +-- Apply with: +-- npx wrangler d1 migrations apply specification-website-websub --remote +-- +-- One row per (topic, callback) pair. The pair is the identity WebSub keys +-- subscriptions on: the same subscriber may hold subscriptions to several +-- topics, and several subscribers may share a topic, but a given callback has +-- at most one subscription per topic. Re-subscribing overrides the previous +-- state for that pair (WebSub §5.1), which is what the primary key gives us +-- for free via INSERT ... ON CONFLICT. +-- +-- `secret` is the subscriber-supplied hub.secret used to HMAC each delivery. +-- It is optional, and it is *their* secret, not ours — we store it only because +-- the protocol requires signing with it. Never logged, never returned. +-- +-- `lease_expires_at` and `created_at` are unix epoch seconds. Leases are +-- enforced lazily: expired rows are deleted at distribution time rather than +-- by a scheduled sweep, so the hub needs no cron trigger. WebSub forbids +-- perpetual leases, so there is no null/sentinel "never expires" value. + +CREATE TABLE IF NOT EXISTS subscriptions ( + topic TEXT NOT NULL, + callback TEXT NOT NULL, + secret TEXT, + lease_expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (topic, callback) +); + +-- Distribution reads active subscribers for one topic; expiry deletes by +-- lease. Both are covered by this composite index. +CREATE INDEX IF NOT EXISTS idx_subscriptions_topic_lease + ON subscriptions (topic, lease_expires_at); diff --git a/package.json b/package.json index 1fca4874..ec12ea74 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "assets": "node scripts/generate-assets.mjs", "sign:ard": "node scripts/sign-ard-catalog.mjs", "check:ard": "node scripts/sign-ard-catalog.mjs --check", + "test:websub": "node --experimental-strip-types scripts/test-websub.mjs", "sign:skill": "node scripts/check-skill.mjs", "check:skill": "node scripts/check-skill.mjs --check" }, diff --git a/public/_headers b/public/_headers index fcd33625..46cdb820 100644 --- a/public/_headers +++ b/public/_headers @@ -49,6 +49,24 @@ Content-Type: text/javascript; charset=utf-8 Cache-Control: public, max-age=600, must-revalidate +# Feeds — WebSub discovery. HELD BACK UNTIL THE HUB IS LIVE: advertising +# rel="hub" while /websub cannot accept subscriptions is the exact failure +# /spec/foundations/websub/ warns about (an advertisement is not a delivery +# mechanism), so these blocks are uncommented in the same commit that +# provisions the D1 database — see the checklist in wrangler.toml. +# +# rel="hub" names the hub that pushes updates for this topic; rel="self" is the +# topic's canonical URL, which is the identity subscribers key on. Subscribers +# must check Link headers before embedded elements (WebSub §4), so this is the +# authoritative copy and the atom:link elements inside each feed are the +# fallback. Keep all three in sync with ALLOWED_TOPICS in functions/websub.ts. +# +# /rss.xml +# Link: ; rel="hub", ; rel="self" +# +# /changelog/rss.xml +# Link: ; rel="hub", ; rel="self" + # Long cache for fingerprinted assets /_astro/* Cache-Control: public, max-age=31536000, immutable diff --git a/scripts/test-websub.mjs b/scripts/test-websub.mjs new file mode 100644 index 00000000..e8828fa8 --- /dev/null +++ b/scripts/test-websub.mjs @@ -0,0 +1,100 @@ +// Assertions for the WebSub hub's security-relevant pure logic. +// +// Run with: npm run test:websub +// +// The hub in functions/websub.ts is the only code in this repo that makes +// outbound requests to URLs supplied by strangers, so the callback guard is +// worth pinning down with a table of cases rather than trusting a read-through. +// Deliberately plain node + node:assert — this repo has no test framework and +// does not need one for four pure functions. +// +// Only the pure helpers are covered. Subscribe/verify/distribute all need a +// D1 binding and a live callback, so they are exercised against the deployed +// hub (see the PR's verification steps), not here. + +import assert from "node:assert/strict"; +import { + isSafeCallback, + clampLease, + timingSafeEqual, + signPayload, +} from "../functions/websub.ts"; + +let failures = 0; + +function check(label, actual, expected) { + try { + assert.deepStrictEqual(actual, expected); + console.log(` ok ${label}`); + } catch { + failures++; + console.log(` FAIL ${label} — got ${actual}, wanted ${expected}`); + } +} + +console.log("\nisSafeCallback — accepts ordinary subscriber callbacks"); +for (const url of [ + "https://reader.example.com/websub/cb", + "https://feeds.example.co.uk/cb?id=42", + "https://a.b.c.example.org/x", + "https://example.com:443/cb", +]) { + check(url, isSafeCallback(url), true); +} + +console.log("\nisSafeCallback — refuses everything that could reach inside"); +for (const url of [ + "http://reader.example.com/cb", // not https + "https://127.0.0.1/cb", // loopback literal + "https://10.0.0.5/cb", // RFC 1918 + "https://192.168.1.1/cb", // RFC 1918 + "https://169.254.169.254/latest/meta-data/", // cloud metadata endpoint + "https://2130706433/cb", // 127.0.0.1 as a decimal integer + "https://[::1]/cb", // IPv6 loopback + "https://localhost/cb", // single-label name + "https://metadata/cb", // single-label name + "https://db.internal/cb", // .internal + "https://printer.local/cb", // mDNS + "https://router.home.arpa/cb", // home network + "https://example.com:8080/cb", // non-443 port + "https://user:pw@example.com/cb", // embedded credentials + "ftp://example.com/cb", // wrong scheme + "javascript:alert(1)", // not a fetchable scheme + "not a url", + "", +]) { + check(url || "(empty string)", isSafeCallback(url), false); +} + +console.log("\nclampLease — WebSub forbids perpetual leases"); +check("null → default", clampLease(null), 864000); +check("non-numeric → default", clampLease("abc"), 864000); +check("zero → default", clampLease("0"), 864000); +check("negative → default", clampLease("-5"), 864000); +check("below floor → floor", clampLease("60"), 3600); +check("above ceiling → ceiling", clampLease("99999999"), 2592000); +check("in range → unchanged", clampLease("86400"), 86400); + +console.log("\ntimingSafeEqual — publish token comparison"); +check("identical", timingSafeEqual("s3cret", "s3cret"), true); +check("last byte differs", timingSafeEqual("s3cret", "s3creT"), false); +check("offered is a prefix", timingSafeEqual("s3c", "s3cret"), false); +check("offered is longer", timingSafeEqual("s3cretXX", "s3cret"), false); +check("both empty", timingSafeEqual("", ""), true); +check("one empty", timingSafeEqual("", "x"), false); + +console.log("\nsignPayload — HMAC-SHA256, RFC 4231 test case 2"); +check( + 'key "Jefe"', + await signPayload( + "Jefe", + new TextEncoder().encode("what do ya want for nothing?").buffer, + ), + "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843", +); + +if (failures > 0) { + console.error(`\n${failures} assertion(s) failed.`); + process.exit(1); +} +console.log("\nAll WebSub hub assertions passed.\n"); diff --git a/src/pages/changelog/rss.xml.ts b/src/pages/changelog/rss.xml.ts index 711b5048..f813f328 100644 --- a/src/pages/changelog/rss.xml.ts +++ b/src/pages/changelog/rss.xml.ts @@ -56,6 +56,9 @@ export async function GET(context: APIContext) { customData: [ "en-GB", ``, + // WebSub hub for this topic — held back until the hub is live, along with + // the Link header in public/_headers. See the checklist in wrangler.toml. + // ``, `${lastBuild.toUTCString()}`, "weekly", "1", diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro index b8caa8a0..ca23f88c 100644 --- a/src/pages/privacy.astro +++ b/src/pages/privacy.astro @@ -2,7 +2,7 @@ import BaseLayout from "~/layouts/BaseLayout.astro"; import Breadcrumbs from "~/components/Breadcrumbs.astro"; -const updated = "2026-06-29"; +const updated = "2026-07-30"; --- +

WebSub subscriptions

+

+ The site has a WebSub hub at + /websub, which pushes updates to our two + feeds to subscribers instead of making them + poll. Subscribing is the one thing on this site that stores data you give + us, so to be explicit about it: +

+
    +
  • + What is stored: if you subscribe, we keep the callback + URL you supplied, the topic you subscribed to, the optional + hub.secret you chose (used only to sign deliveries to you, + never logged and never disclosed), and your lease expiry time. Nothing else + — no IP address, no User-Agent. +
  • +
  • + How long: until the lease expires, at which point the row + is deleted. Leases last at most 30 days, so an abandoned subscription removes + itself. You can unsubscribe at any time by POSTing + hub.mode=unsubscribe, which deletes the row immediately + once your callback confirms. +
  • +
  • + Who sees it: nobody outside the hub. Subscriber lists are + not published, not shared, and not fed into the + /admin/stats dashboard. +
  • +
+

+ A callback URL can be personally identifying if you host it yourself, so + treat subscribing as sharing that URL with us. If you would rather not, + poll the feed — it works exactly as well, just less promptly. +

+

GitHub

Source content lives on en-GB", ``, + // WebSub hub for this topic — held back until the hub is live, along with + // the Link header in public/_headers. See the checklist in wrangler.toml. + // ``, `${lastBuild.toUTCString()}`, "weekly", "1", diff --git a/wrangler.toml b/wrangler.toml index be091626..6ef09684 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -24,3 +24,47 @@ dataset = "sw_agent_log" [[analytics_engine_datasets]] binding = "REPORT_LOG" dataset = "sw_report_log" + +# D1 — WebSub subscription store, read/written by functions/websub.ts. +# +# COMMENTED OUT UNTIL THE DATABASE EXISTS. The hub returns 503 without this +# binding, so the site deploys and behaves normally in the meantime. +# +# --------------------------------------------------------------------------- +# GO-LIVE CHECKLIST for the WebSub hub. Do all of it in one commit, so the site +# never advertises a hub it cannot serve — a rel="hub" link with nothing behind +# it is the exact failure /spec/foundations/websub/ warns about. +# +# 1. Create the database and note the id it prints: +# npx wrangler d1 create specification-website-websub +# +# 2. Paste that id below and uncomment the [[d1_databases]] block. +# +# 3. Apply the schema: +# npx wrangler d1 migrations apply specification-website-websub --remote +# +# 4. Add the WEBSUB_PUBLISH_SECRET Pages secret (Settings → Variables and +# Secrets, Production + Preview) — the bearer token that authorises +# hub.mode=publish. Add the same value as a GitHub Actions repo secret of +# the same name, which .github/workflows/websub-ping.yml reads. +# +# 5. Advertise the hub — this is the step that makes it discoverable: +# - uncomment the two feed blocks in public/_headers; +# - uncomment the `atom:link rel="hub"` line in src/pages/rss.xml.ts and +# in src/pages/changelog/rss.xml.ts; +# - add a "hub" entry to public/.well-known/api-catalog pointing at +# https://specification.website/websub. +# +# 6. Add the worked-example callout to +# src/content/spec/foundations/websub.md — the site is now an example of +# the page, and CLAUDE.md requires the page to say so. +# +# 7. Verify against production: `curl -sI https://specification.website/rss.xml +# | grep -i '^link'` shows rel="hub", and the manual `workflow_dispatch` run +# of websub-ping returns 202. +# --------------------------------------------------------------------------- +# +# [[d1_databases]] +# binding = "WEBSUB_DB" +# database_name = "specification-website-websub" +# database_id = "PASTE_ID_FROM_STEP_1"