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 a7afce0b..688a6ea9 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "assets": "node scripts/generate-assets.mjs", + "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 aba31e22..8642a62a 100644 --- a/public/_headers +++ b/public/_headers @@ -64,6 +64,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"