Files
nngo cc216b0d98
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
Aug 26 - Enhance security 3
2026-08-26 13:54:48 -04:00

213 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""
Regenerate extension/shared/psl.js from the Public Suffix List.
python scripts/update_psl.py
The extension has no build step — it is zipped as-is — so the list is vendored
as a plain classic script rather than pulled in via npm/bundler.
Why the extension needs this at all: autofill decides whether a stored
credential belongs to the page you are on. Plain suffix comparison treats
`evil.github.io` and `victim.github.io` as the same site, because `github.io`
looks like an ordinary domain. The PSL is the only way to know that it is a
public suffix and that those are different sites.
BOTH sections are included, deliberately:
ICANN — real TLDs (co.uk, com.au, ...)
PRIVATE — github.io, vercel.app, herokuapp.com, ...
The PRIVATE section is the one that matters most here: those are the hosts where
an attacker can actually obtain a neighbouring subdomain.
Re-run when the list goes stale (it changes a few times a month). The generated
file records the upstream VERSION header so staleness is visible in a diff.
"""
import pathlib
import re
import sys
import urllib.request
PSL_URL = 'https://publicsuffix.org/list/public_suffix_list.dat'
OUT = pathlib.Path(__file__).resolve().parent.parent / 'extension' / 'shared' / 'psl.js'
HEADER = '''/**
* extension/shared/psl.js — GENERATED FILE, DO NOT EDIT BY HAND.
*
* Regenerate with: python scripts/update_psl.py
*
* Vendored Public Suffix List (https://publicsuffix.org/), used to decide
* whether two hostnames belong to the same site before offering a stored
* credential for autofill.
*
* Source list version: {version}
* Rules: {n_rules} exact, {n_wild} wildcard, {n_exc} exception
*
* The list is MPL-2.0 licensed; see https://mozilla.org/MPL/2.0/.
*
* Exposes a single global, PkPsl, with:
* getRegistrableDomain(host) -> "example.co.uk" | null
* isSameSite(hostA, hostB) -> boolean
*/
'''
BODY = r'''
const PkPsl = (() => {
"use strict";
// Split from single strings rather than array literals — same data, far less
// punctuation, and the parse cost is a one-off at script load.
const RULES = new Set(EXACT_BLOB.split("\n"));
const WILDCARDS = new Set(WILD_BLOB ? WILD_BLOB.split("\n") : []);
const EXCEPTIONS = new Set(EXC_BLOB ? EXC_BLOB.split("\n") : []);
const IPV4_RE = /^\d{1,3}(\.\d{1,3}){3}$/;
function _normalise(host) {
if (!host) return null;
let h = String(host).trim().toLowerCase();
if (h.endsWith(".")) h = h.slice(0, -1); // trailing root dot
return h || null;
}
/**
* Number of trailing labels that form the public suffix of `labels`.
* Implements the matching rules from https://publicsuffix.org/list/:
* exception rules win outright, otherwise the longest match wins, and an
* unmatched host falls back to the implicit "*" rule.
*/
function _publicSuffixLength(labels) {
// Exception rules (!foo.bar) take priority over everything else.
for (let i = 0; i < labels.length; i++) {
if (EXCEPTIONS.has(labels.slice(i).join("."))) {
return labels.length - i - 1;
}
}
let best = 0;
for (let i = 0; i < labels.length; i++) {
const len = labels.length - i;
if (len <= best) continue;
if (RULES.has(labels.slice(i).join("."))) {
best = len;
continue;
}
// A wildcard rule "*.x.y" matches when labels[i] is any single label and
// the remainder equals "x.y".
if (i + 1 <= labels.length - 1 &&
WILDCARDS.has(labels.slice(i + 1).join("."))) {
best = len;
}
}
// No rule matched: the implicit "*" rule makes the rightmost label the
// public suffix (so "example.invalidtld" is still a registrable domain).
return best || 1;
}
/**
* The registrable domain ("example.co.uk") for a hostname, or null when the
* host has none — an IP address, a single label like "localhost", or a host
* that IS a public suffix ("github.io" itself).
*
* Callers must treat null as "no site identity": fall back to exact hostname
* equality rather than assuming a match.
*/
function getRegistrableDomain(host) {
const h = _normalise(host);
if (!h) return null;
if (IPV4_RE.test(h) || h.includes(":")) return null; // IPv4 / IPv6
const labels = h.split(".");
if (labels.length < 2) return null; // "localhost"
const suffixLen = _publicSuffixLength(labels);
if (labels.length <= suffixLen) return null; // host is itself a suffix
return labels.slice(labels.length - suffixLen - 1).join(".");
}
/**
* True when two hostnames belong to the same registrable site.
*
* When either host has no registrable domain (IP, localhost, or a bare public
* suffix) this falls back to exact hostname equality — never to a suffix
* comparison, which is what allowed evil.github.io to match victim.github.io.
*/
function isSameSite(a, b) {
const ha = _normalise(a);
const hb = _normalise(b);
if (!ha || !hb) return false;
if (ha === hb) return true;
const da = getRegistrableDomain(ha);
const db = getRegistrableDomain(hb);
if (!da || !db) return false;
return da === db;
}
return { getRegistrableDomain, isSameSite };
})();
// Content scripts and the popup load this as a classic script; the service
// worker imports it via importScripts. Export only where a module system exists.
if (typeof module !== "undefined" && module.exports) {
module.exports = PkPsl;
}
'''
def main():
print(f'Fetching {PSL_URL} ...')
with urllib.request.urlopen(PSL_URL, timeout=60) as resp:
text = resp.read().decode('utf-8')
version = 'unknown'
m = re.search(r'^// VERSION:\s*(.+)$', text, re.M)
if m:
version = m.group(1).strip()
exact, wildcards, exceptions = [], [], []
for line in text.splitlines():
rule = line.strip()
if not rule or rule.startswith('//'):
continue
if rule.startswith('!'):
exceptions.append(rule[1:])
elif rule.startswith('*.'):
wildcards.append(rule[2:])
elif '*' in rule:
# No such rules exist today (wildcards are always leftmost). Skip
# loudly rather than silently mis-parsing if that ever changes.
print(f' WARNING: skipping unsupported rule {rule!r}', file=sys.stderr)
else:
exact.append(rule)
if len(exact) < 5000:
sys.exit(f'FAIL: only {len(exact)} exact rules parsed — the list looks truncated')
for expected in ('github.io', 'vercel.app', 'co.uk'):
if expected not in exact:
sys.exit(f'FAIL: expected rule {expected!r} missing — parse is wrong')
def blob(name, values):
return f' const {name} = `' + '\n'.join(sorted(set(values))) + '`;\n'
out = HEADER.format(version=version, n_rules=len(exact),
n_wild=len(wildcards), n_exc=len(exceptions))
out += '\n// eslint-disable-next-line no-unused-vars\n'
out += 'const _PSL_DATA = (() => {\n'
out += blob('EXACT_BLOB', exact)
out += blob('WILD_BLOB', wildcards)
out += blob('EXC_BLOB', exceptions)
out += ' return { EXACT_BLOB, WILD_BLOB, EXC_BLOB };\n})();\n'
out += '\nconst { EXACT_BLOB, WILD_BLOB, EXC_BLOB } = _PSL_DATA;\n'
out += BODY
OUT.write_text(out, encoding='utf-8', newline='\n')
size_kb = OUT.stat().st_size / 1024
print(f'Wrote {OUT.relative_to(OUT.parent.parent.parent)} '
f'({size_kb:.0f} KB) — version {version}')
print(f' {len(exact)} exact, {len(wildcards)} wildcard, {len(exceptions)} exception rules')
if __name__ == '__main__':
main()