From 371fe56ad51039a7ac67999d8b2e443dfe7fbf20 Mon Sep 17 00:00:00 2001 From: tmxnova Date: Tue, 8 Sep 2026 13:12:53 +0700 Subject: [PATCH 1/3] Harden redirect safety: blocklist per-OS deep links + IDN, fail-closed custom host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enforce the destination blocklist on iosUrl/androidUrl/desktopUrl (create + update). Only destination and geoRules were checked, yet per-OS deep links route straight from the cached payload (cache.ts routeDestination), so one could point a device-specific target at a blocked domain and bypass the block. - Normalize hosts in isBlockedDestination: strip trailing FQDN dot(s) from the destination host and blocklist entries ("evil.com." bypass), and punycode- encode IDN entries so a Unicode blocklist entry matches new URL()'s ASCII destination host (and vice versa). - Fail closed when a custom host's domain lookup fails (KV miss + DB error): a new LOOKUP_FAILED sentinel distinguishes a hard failure from a definitive "no such domain", and every /:slug consumer of resolveScope — the redirect, POST /api/unlock/:slug, GET /api/qr/:slug and GET /qr/:file.svg — now 404s instead of resolving the host against the unrelated default bucket (which could, e.g., serve/unlock an unrelated default-host link at the same slug). - Add scripts/seed-api-key-d1.mjs to mint the first API key on a D1 deploy without the dashboard (resolves database_id like d1-migrate.mjs for --remote). - Add tests/blocklist.ts (+ test:blocklist), including real IDN conversion. --- package.json | 1 + scripts/seed-api-key-d1.mjs | 95 +++++++++++++++++++++++++++++++++++++ tests/blocklist.ts | 49 +++++++++++++++++++ worker/index.ts | 16 +++++++ worker/lib/domainScope.ts | 29 +++++++++-- worker/lib/settings.ts | 17 +++++-- worker/routes/links.ts | 18 +++++++ 7 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 scripts/seed-api-key-d1.mjs create mode 100644 tests/blocklist.ts diff --git a/package.json b/package.json index 881a98e..d1127cf 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:captcha:flow": "node --env-file-if-exists=.dev.vars node_modules/tsx/dist/cli.mjs tests/captcha-flow.ts", "test:password": "tsx tests/password.ts", "test:routing": "tsx tests/routing.ts", + "test:blocklist": "tsx tests/blocklist.ts", "test:ai": "tsx tests/ai-assistant.ts", "test:captcha:risk": "tsx tests/captcha-risk.ts" }, diff --git a/scripts/seed-api-key-d1.mjs b/scripts/seed-api-key-d1.mjs new file mode 100644 index 0000000..021a7e3 --- /dev/null +++ b/scripts/seed-api-key-d1.mjs @@ -0,0 +1,95 @@ +/** + * Dev/ops helper: mint an API key on a D1 deployment and print it once, so a + * headless/fleet install can create its first key without the dashboard. + * (Postgres deployments use scripts/seed-api-key.ts instead.) + * + * node scripts/seed-api-key-d1.mjs [name] [--local] + * + * Talks to the D1 database bound as DB in wrangler.jsonc (shortlink-db) via + * `wrangler d1 execute`. Default is --remote (production); pass --local for the + * local dev DB. Create the admin user at /setup first. + * + * wrangler.jsonc deliberately omits database_id (one-click auto-provisions by + * name), so `--remote` needs the id resolved into a throwaway config — the same + * dance as scripts/d1-migrate.mjs. --local needs no id. + */ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync, rmSync } from "node:fs"; + +const DB = "shortlink-db"; +const args = process.argv.slice(2); +const local = args.includes("--local"); +const [email, name = "fleet"] = args.filter((a) => !a.startsWith("--")); +if (!email) { + console.error("usage: node scripts/seed-api-key-d1.mjs [name] [--local]"); + process.exit(1); +} + +let accountId = process.env.CLOUDFLARE_ACCOUNT_ID || undefined; +try { + const cfg = JSON.parse(readFileSync("dist/shortlink/wrangler.json", "utf8")); + if (!accountId && cfg.account_id) accountId = cfg.account_id; +} catch { + /* no built config — rely on wrangler's own account context */ +} +const env = accountId ? { ...process.env, CLOUDFLARE_ACCOUNT_ID: accountId } : process.env; +const wrangler = (a) => execFileSync("npx", ["wrangler", ...a], { encoding: "utf8", env }); +const parseJson = (out) => { + const i = out.search(/[[{]/); + if (i < 0) throw new Error("no JSON in wrangler output"); + return JSON.parse(out.slice(i)); +}; + +// For --remote, resolve the database_id and hand execute a throwaway config. +let cfgFlag = []; +let cleanup = () => {}; +if (!local) { + const idOf = (d) => d?.uuid ?? d?.id ?? d?.database_id; + let databaseId; + try { + databaseId = idOf(parseJson(wrangler(["d1", "info", DB, "--json"]))); + } catch { + const list = parseJson(wrangler(["d1", "list", "--json"])); + const arr = Array.isArray(list) ? list : (list?.result ?? list?.databases ?? []); + databaseId = idOf(arr.find((d) => d.name === DB) || {}); + } + if (!databaseId) { + console.error(`Could not resolve database_id for "${DB}" (wrangler login / CLOUDFLARE_ACCOUNT_ID?).`); + process.exit(1); + } + const file = "d1-seed.generated.json"; + const gen = { name: "shortlink", d1_databases: [{ binding: "DB", database_name: DB, database_id: databaseId }] }; + if (accountId) gen.account_id = accountId; + writeFileSync(file, JSON.stringify(gen)); + cfgFlag = ["-c", file]; + cleanup = () => { try { rmSync(file); } catch { /* ignore */ } }; +} + +const scope = local ? "--local" : "--remote"; +const esc = (s) => String(s).replace(/'/g, "''"); +const exec = (sql) => parseJson(wrangler(["d1", "execute", DB, scope, ...cfgFlag, "--json", "--command", sql])); + +try { + const found = exec(`select id from users where lower(email) = lower('${esc(email)}') limit 1`); + const rows = found?.[0]?.results ?? []; + if (rows.length === 0) { + console.error(`No user with email ${email}. Create the admin at /setup first.`); + process.exit(1); + } + const userId = rows[0].id; + + const key = `sk_${randomBytes(24).toString("hex")}`; + const keyHash = createHash("sha256").update(key).digest("hex"); + const prefix = key.slice(0, 11); // "sk_" + 8 chars + const id = randomUUID(); + const nowSec = Math.floor(Date.now() / 1000); // api_keys.created_at = integer(timestamp) seconds + + exec( + `insert into api_keys (id, user_id, name, key_hash, prefix, created_at) ` + + `values ('${id}','${esc(userId)}','${esc(name)}','${keyHash}','${prefix}',${nowSec})`, + ); + console.log(`API key for ${email} ("${name}"):\n${key}\n(store it now — only its SHA-256 is kept)`); +} finally { + cleanup(); +} diff --git a/tests/blocklist.ts b/tests/blocklist.ts new file mode 100644 index 0000000..5344777 --- /dev/null +++ b/tests/blocklist.ts @@ -0,0 +1,49 @@ +/** + * Unit tests for the destination blocklist matcher (worker/lib/settings.ts + * isBlockedDestination). Run: `npx tsx tests/blocklist.ts` (pure function, no DB). + * + * Covers the normalization fixes: a trailing FQDN dot, and IDN — where the + * destination host is punycode-encoded by new URL() but a Unicode blocklist + * entry must still match (and vice versa) — plus wildcard/subdomain behavior. + */ +import { isBlockedDestination } from "../worker/lib/settings"; + +let pass = 0; +let fail = 0; +function check(label: string, got: boolean, exp: boolean) { + if (got === exp) { + pass++; + console.log(" ✓", label); + } else { + fail++; + console.log(" ✗", label, `→ got ${got}, expected ${exp}`); + } +} + +const blocked = ["evil.com", "*.bad.example", "phish.net."]; + +check("exact host blocked", isBlockedDestination("https://evil.com/x", blocked), true); +check("subdomain of exact blocked", isBlockedDestination("https://a.evil.com/x", blocked), true); +check("trailing-dot FQDN bypass closed", isBlockedDestination("https://evil.com./x", blocked), true); +check("trailing-dot on subdomain closed", isBlockedDestination("https://a.evil.com./x", blocked), true); +check("wildcard entry matches subdomain", isBlockedDestination("https://x.bad.example/y", blocked), true); +check("wildcard entry matches apex", isBlockedDestination("https://bad.example/y", blocked), true); +check("blocklist entry with trailing dot still matches", isBlockedDestination("https://phish.net/z", blocked), true); +check("unrelated host allowed", isBlockedDestination("https://good.com/x", blocked), false); +check("lookalike suffix not over-matched", isBlockedDestination("https://notevil.com/x", blocked), false); +check("empty blocklist allows all", isBlockedDestination("https://evil.com/x", []), false); +check("malformed url is not blocked (fails safe to caller)", isBlockedDestination("not a url", blocked), false); + +// IDN: a Unicode blocklist entry must match a real Unicode destination. new URL() +// punycode-encodes the destination host, so the matcher has to punycode the entry +// too. Build the punycode form via new URL() rather than hardcoding it. +const unicodeHost = "пример.example"; +const punycodeHost = new URL(`https://${unicodeHost}`).hostname; // e.g. xn--e1afmkfd.example +check("IDN: unicode entry vs unicode destination", isBlockedDestination(`https://${unicodeHost}/x`, [unicodeHost]), true); +check("IDN: unicode entry vs punycode destination", isBlockedDestination(`https://${punycodeHost}/x`, [unicodeHost]), true); +check("IDN: punycode entry vs unicode destination", isBlockedDestination(`https://${unicodeHost}/x`, [punycodeHost]), true); +check("IDN: subdomain of unicode entry", isBlockedDestination(`https://a.${unicodeHost}/x`, [unicodeHost]), true); +check("IDN: unrelated unicode host allowed", isBlockedDestination("https://другой.example/x", [unicodeHost]), false); + +console.log(`\nblocklist: ${pass} passed, ${fail} failed`); +if (fail > 0) process.exit(1); diff --git a/worker/index.ts b/worker/index.ts index bdafd64..72ceefc 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -120,6 +120,10 @@ app.get("/api/qr/:slug", async (c) => { const slug = c.req.param("slug"); if (!isValidCustomSlug(slug)) return c.json({ error: "Not found" }, 404); const scope = await resolveScope(c, c.req.header("host")); + + // Custom host we couldn't resolve (lookup failed) — fail closed rather than + // serving an unrelated default-host link at this slug. + if (scope.unresolved) return c.json({ error: "Not found" }, 404); const { db, schema, close } = getDbHandle(c.env); const { projects } = schema; try { @@ -167,6 +171,10 @@ app.post("/api/unlock/:slug", async (c) => { const body = await c.req.parseBody(); const password = typeof body.password === "string" ? body.password : ""; const scope = await resolveScope(c, c.req.header("host")); + + // Custom host we couldn't resolve (lookup failed) — fail closed rather than + // serving an unrelated default-host link at this slug. + if (scope.unresolved) return linkErrorPage(c, "not-found"); const { db, schema, close } = getDbHandle(c.env); try { // Throttle online password guessing: the no-JS unlock page has no human @@ -264,6 +272,10 @@ app.get("/qr/:file", async (c) => { if (!m) return serveAssets(c); // not a .svg request → serve the SPA page const slug = m[1]; const scope = await resolveScope(c, c.req.header("host")); + + // Custom host we couldn't resolve (lookup failed) — fail closed rather than + // serving an unrelated default-host link at this slug. + if (scope.unresolved) return linkErrorPage(c, "not-found"); const { db, schema, close } = getDbHandle(c.env); const { projects } = schema; try { @@ -312,6 +324,10 @@ app.get("/:slug", async (c) => { // hosts, so every lookup is scoped to one domain bucket. const scope = await resolveScope(c, c.req.header("host")); + // Custom host we couldn't resolve (lookup failed) — fail closed rather than + // serving an unrelated default-host link at this slug. + if (scope.unresolved) return linkErrorPage(c, "not-found"); + // Social crawlers (FB/X/IG/Slack/…) get an OG-tagged preview instead of the // redirect, so a shared link can show a branded card. Bots don't run JS and // aren't counted as clicks; humans always fall through to the fast path. diff --git a/worker/lib/domainScope.ts b/worker/lib/domainScope.ts index 7b43c69..3b6b015 100644 --- a/worker/lib/domainScope.ts +++ b/worker/lib/domainScope.ts @@ -14,8 +14,17 @@ interface DomainScope { domainId: string | null; /** Hostname to build this link's short URL on. */ host: string; + /** Set when a custom host could not be resolved because the domain lookup + * failed (KV miss/blip + DB error); the caller must fail closed (404) + * instead of serving the host from the unrelated default bucket. */ + unresolved?: boolean; } +// Distinguishes a hard lookup failure (DB error) from a definitive "no such +// custom domain" (null), so a custom host fails closed on an outage instead of +// leaking default-host links. Deliberately NOT memoized (transient). +const LOOKUP_FAILED: unique symbol = Symbol("domain-lookup-failed"); + /** The canonical default short host, derived from APP_URL. */ function appHost(env: AppBindings): string { try { @@ -59,6 +68,12 @@ export async function resolveScope( return { domainId: null, host: fallback }; } const domainId = await resolveDomainId(c, host); + if (domainId === LOOKUP_FAILED) { + // Custom host whose domain lookup failed (KV miss + DB error). Fail CLOSED: + // do NOT fall back to the default bucket, or `custom.example/abc` could + // serve the unrelated default-host `/abc`. + return { domainId: null, host, unresolved: true }; + } return domainId ? { domainId, host } : { domainId: null, host: fallback }; } @@ -66,7 +81,10 @@ export async function resolveScope( * layer degrades to the next on failure, and a total failure falls back to the * default bucket (null) so the redirect still resolves a default-host link * rather than 500ing when KV or the DB is unavailable. */ -async function resolveDomainId(c: AppContext, host: string): Promise { +async function resolveDomainId( + c: AppContext, + host: string, +): Promise { const now = Date.now(); const memo = hostMemo.get(host); if (memo && memo.until > now) return memo.id; @@ -103,10 +121,11 @@ async function resolveDomainId(c: AppContext, host: string): Promise "evil.com") so a fully-qualified + // hostname can't slip past the blocklist. new URL() also lowercases and + // punycode-encodes IDN hosts, so the compared host is always ASCII. + host = new URL(destination).hostname.toLowerCase().replace(/\.+$/, ""); } catch { return false; } return blocked.some((d) => { - const dom = d.trim().toLowerCase().replace(/^\*?\.?/, ""); - return dom !== "" && (host === dom || host.endsWith(`.${dom}`)); + let dom = d.trim().toLowerCase().replace(/^\*?\.?/, "").replace(/\.+$/, ""); + if (dom === "") return false; + // Normalize IDN entries to punycode so a Unicode blocklist entry still + // matches new URL()'s ASCII (punycode) destination host, and vice versa. + try { + dom = new URL(`https://${dom}`).hostname; + } catch { + // Not a parseable host — compare it literally. + } + return host === dom || host.endsWith(`.${dom}`); }); } diff --git a/worker/routes/links.ts b/worker/routes/links.ts index 9da0722..a6c0be6 100644 --- a/worker/routes/links.ts +++ b/worker/routes/links.ts @@ -335,6 +335,12 @@ route.post("/", zValidator("json", createLinkSchema), async (c) => { ) { return c.json({ error: "A country-routing destination domain isn’t allowed" }, 400); } + const blockedDeep = [input.iosUrl, input.androidUrl, input.desktopUrl].filter( + (u): u is string => typeof u === "string" && u.length > 0, + ); + if (blockedDeep.some((u) => isBlockedDestination(u, blockedDomainsFrom(settings)))) { + return c.json({ error: "A device-specific destination domain isn’t allowed" }, 400); + } if (input.slug && extraReservedFrom(settings).includes(input.slug.toLowerCase())) { return c.json({ error: "That custom alias is reserved" }, 400); } @@ -855,6 +861,18 @@ route.patch("/:id", zValidator("json", updateLinkSchema), async (c) => { } } + { + const deep = [input.iosUrl, input.androidUrl, input.desktopUrl].filter( + (u): u is string => typeof u === "string" && u.length > 0, + ); + if (deep.length > 0) { + settings ??= await getAllSettings(db, schema); + if (deep.some((u) => isBlockedDestination(u, blockedDomainsFrom(settings!)))) { + return c.json({ error: "A device-specific destination domain isn’t allowed" }, 400); + } + } + } + const patch: Partial = { updatedAt: new Date() }; if (input.destination !== undefined) patch.destination = input.destination; if (input.iosUrl !== undefined) patch.iosUrl = input.iosUrl; From eca124d637ee531f067743230d20e10837a423f6 Mon Sep 17 00:00:00 2001 From: tmxnova Date: Tue, 8 Sep 2026 13:15:25 +0700 Subject: [PATCH 2/3] Fix IDN blocklist normalization order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip trailing dot(s) AFTER the IDNA conversion of a blocklist entry, not only before. Unicode dot separators (U+3002 。/ U+FF0E ./ U+FF61 。) only become ASCII dots inside new URL(), so a pre-conversion strip left the converted entry with a trailing dot (e.g. "пример.example。" -> "xn--…example.") that no longer matched the dot-stripped destination host. Add regression cases for all three separators. --- tests/blocklist.ts | 3 +++ worker/lib/settings.ts | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/blocklist.ts b/tests/blocklist.ts index 5344777..07bf5fa 100644 --- a/tests/blocklist.ts +++ b/tests/blocklist.ts @@ -44,6 +44,9 @@ check("IDN: unicode entry vs punycode destination", isBlockedDestination(`https: check("IDN: punycode entry vs unicode destination", isBlockedDestination(`https://${unicodeHost}/x`, [punycodeHost]), true); check("IDN: subdomain of unicode entry", isBlockedDestination(`https://a.${unicodeHost}/x`, [unicodeHost]), true); check("IDN: unrelated unicode host allowed", isBlockedDestination("https://другой.example/x", [unicodeHost]), false); +check("IDN: entry with ideographic full stop U+3002", isBlockedDestination(`https://${unicodeHost}/`, ["пример.example。"]), true); +check("IDN: entry with fullwidth full stop U+FF0E", isBlockedDestination(`https://${unicodeHost}/`, ["пример.example"]), true); +check("IDN: entry with halfwidth ideographic stop U+FF61", isBlockedDestination(`https://${unicodeHost}/`, ["пример.example。"]), true); console.log(`\nblocklist: ${pass} passed, ${fail} failed`); if (fail > 0) process.exit(1); diff --git a/worker/lib/settings.ts b/worker/lib/settings.ts index d15762e..7eebe8e 100644 --- a/worker/lib/settings.ts +++ b/worker/lib/settings.ts @@ -676,7 +676,10 @@ export function isBlockedDestination( // Normalize IDN entries to punycode so a Unicode blocklist entry still // matches new URL()'s ASCII (punycode) destination host, and vice versa. try { - dom = new URL(`https://${dom}`).hostname; + // Strip AFTER IDNA conversion too: Unicode dot separators (U+3002/FF0E/FF61) + // only become ASCII dots here, so a trailing one would re-appear and break + // the match against the (dot-stripped) destination host. + dom = new URL(`https://${dom}`).hostname.replace(/\.+$/, ""); } catch { // Not a parseable host — compare it literally. } From 521b4faf221f2e9ad7291635cc0ec9f67c9b8116 Mon Sep 17 00:00:00 2001 From: tmxnova Date: Tue, 8 Sep 2026 13:17:39 +0700 Subject: [PATCH 3/3] test: cover trailing U+FF0E separator explicitly The U+FF0E case used an interior fullwidth dot (a label separator), not a trailing one, so it didn't exercise the after-IDNA trailing-strip it was meant to. Add a trailing-U+FF0E case and keep the interior one (relabeled). --- tests/blocklist.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/blocklist.ts b/tests/blocklist.ts index 07bf5fa..1c775b7 100644 --- a/tests/blocklist.ts +++ b/tests/blocklist.ts @@ -45,7 +45,8 @@ check("IDN: punycode entry vs unicode destination", isBlockedDestination(`https: check("IDN: subdomain of unicode entry", isBlockedDestination(`https://a.${unicodeHost}/x`, [unicodeHost]), true); check("IDN: unrelated unicode host allowed", isBlockedDestination("https://другой.example/x", [unicodeHost]), false); check("IDN: entry with ideographic full stop U+3002", isBlockedDestination(`https://${unicodeHost}/`, ["пример.example。"]), true); -check("IDN: entry with fullwidth full stop U+FF0E", isBlockedDestination(`https://${unicodeHost}/`, ["пример.example"]), true); +check("IDN: entry with trailing fullwidth full stop U+FF0E", isBlockedDestination(`https://${unicodeHost}/`, ["пример.example."]), true); +check("IDN: entry with interior fullwidth full stop U+FF0E", isBlockedDestination(`https://${unicodeHost}/`, ["пример.example"]), true); check("IDN: entry with halfwidth ideographic stop U+FF61", isBlockedDestination(`https://${unicodeHost}/`, ["пример.example。"]), true); console.log(`\nblocklist: ${pass} passed, ${fail} failed`);