Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
95 changes: 95 additions & 0 deletions scripts/seed-api-key-d1.mjs
Original file line number Diff line number Diff line change
@@ -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 <email> [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 <email> [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();
}
53 changes: 53 additions & 0 deletions tests/blocklist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* 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);
check("IDN: entry with ideographic full stop U+3002", 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`);
if (fail > 0) process.exit(1);
16 changes: 16 additions & 0 deletions worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
29 changes: 24 additions & 5 deletions worker/lib/domainScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -59,14 +68,23 @@ 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 };
}

/** Resolve a custom-domain id from a hostname (in-isolate memo → KV → DB). Every
* 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<string | null> {
async function resolveDomainId(
c: AppContext,
host: string,
): Promise<string | null | typeof LOOKUP_FAILED> {
const now = Date.now();
const memo = hostMemo.get(host);
if (memo && memo.until > now) return memo.id;
Expand Down Expand Up @@ -103,10 +121,11 @@ async function resolveDomainId(c: AppContext, host: string): Promise<string | nu
c.executionCtx.waitUntil(close());
}
} catch {
// DB also unavailable — fall back to the default bucket so default-host
// links keep resolving. (A custom-domain link can't resolve in this state,
// but the visitor gets a branded 404, never a 500.)
return null;
// The domain lookup failed (KV missed and the DB errored). We cannot tell
// whether this custom host maps to a real domain, so signal a hard failure
// and let the caller fail CLOSED (404) rather than serving it from the
// default bucket.
return LOOKUP_FAILED;
}
}

Expand Down
20 changes: 17 additions & 3 deletions worker/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,13 +663,27 @@ export function isBlockedDestination(
if (blocked.length === 0) return false;
let host: string;
try {
host = new URL(destination).hostname.toLowerCase();
// Strip trailing dot(s) ("evil.com." -> "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 {
// 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.
}
return host === dom || host.endsWith(`.${dom}`);
});
}

Expand Down
18 changes: 18 additions & 0 deletions worker/routes/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<typeof links.$inferInsert> = { updatedAt: new Date() };
if (input.destination !== undefined) patch.destination = input.destination;
if (input.iosUrl !== undefined) patch.iosUrl = input.iosUrl;
Expand Down
Loading