/** * tests/js/test_psl.js — run with: node tests/js/test_psl.js * * Covers extension/shared/psl.js, which decides whether a stored credential * belongs to the page being viewed (review finding #5). * * The old matcher in content.js compared hostnames by plain suffix: * * h === host || h.endsWith("." + host) || host.endsWith("." + h) * * which treated evil.github.io and victim.github.io as the same site, and let * an item saved for a bare TLD match every site under it. The attack cases * below pin that shut; the Mozilla vectors verify the PSL algorithm itself. */ 'use strict'; const fs = require('fs'); const path = require('path'); const vm = require('vm'); const PSL_PATH = path.join(__dirname, '..', '..', 'extension', 'shared', 'psl.js'); // Load as a classic script in a bare context, the way a content script sees it. const ctx = vm.createContext({}); vm.runInContext(fs.readFileSync(PSL_PATH, 'utf8'), ctx, { filename: 'psl.js' }); const PkPsl = vm.runInContext('PkPsl', ctx); let failures = 0; function check(label, got, want) { if (got !== want) { failures++; console.log(`FAIL ${label}\n got=${JSON.stringify(got)} want=${JSON.stringify(want)}`); } } // ── Canonical vectors from Mozilla's PSL test suite ───────────────────────── // https://github.com/publicsuffix/list/blob/master/tests/test_psl.txt const MOZILLA_VECTORS = [ ['example.COM', 'example.com'], ['WwW.example.COM', 'example.com'], ['example', null], ['b.example', 'b.example'], ['a.b.example', 'b.example'], ['biz', null], ['domain.biz', 'domain.biz'], ['b.domain.biz', 'domain.biz'], ['a.b.domain.biz', 'domain.biz'], ['com', null], ['example.com', 'example.com'], ['b.example.com', 'example.com'], ['a.b.example.com', 'example.com'], ['uk.com', null], ['example.uk.com', 'example.uk.com'], ['b.example.uk.com', 'example.uk.com'], ['a.b.example.uk.com', 'example.uk.com'], ['test.ac', 'test.ac'], // TLD with only a wildcard rule ['mm', null], ['c.mm', null], ['b.c.mm', 'b.c.mm'], ['a.b.c.mm', 'b.c.mm'], // More complex TLD ['jp', null], ['test.jp', 'test.jp'], ['www.test.jp', 'test.jp'], ['ac.jp', null], ['test.ac.jp', 'test.ac.jp'], ['www.test.ac.jp', 'test.ac.jp'], ['kyoto.jp', null], ['test.kyoto.jp', 'test.kyoto.jp'], ['ide.kyoto.jp', null], ['b.ide.kyoto.jp', 'b.ide.kyoto.jp'], ['a.b.ide.kyoto.jp', 'b.ide.kyoto.jp'], ['c.kobe.jp', null], ['b.c.kobe.jp', 'b.c.kobe.jp'], ['a.b.c.kobe.jp', 'b.c.kobe.jp'], ['city.kobe.jp', 'city.kobe.jp'], ['www.city.kobe.jp', 'city.kobe.jp'], // Wildcard rule plus exceptions ['ck', null], ['test.ck', null], ['b.test.ck', 'b.test.ck'], ['a.b.test.ck', 'b.test.ck'], ['www.ck', 'www.ck'], ['www.www.ck', 'www.ck'], ['us', null], ['test.us', 'test.us'], ['www.test.us', 'test.us'], ['ak.us', null], ['test.ak.us', 'test.ak.us'], ['www.test.ak.us', 'test.ak.us'], ['k12.ak.us', null], ['test.k12.ak.us', 'test.k12.ak.us'], ['www.test.k12.ak.us', 'test.k12.ak.us'], ]; for (const [input, want] of MOZILLA_VECTORS) { check(`getRegistrableDomain(${input})`, PkPsl.getRegistrableDomain(input), want); } // ── The PRIVATE section must be present ───────────────────────────────────── // These are the suffixes where an attacker can actually register a neighbouring // subdomain, so dropping the PRIVATE section would silently reopen the hole. check('github.io is a public suffix', PkPsl.getRegistrableDomain('github.io'), null); check('vercel.app is a public suffix', PkPsl.getRegistrableDomain('vercel.app'), null); check('user.github.io is registrable', PkPsl.getRegistrableDomain('victim.github.io'), 'victim.github.io'); // ── Attack cases: these must NOT be treated as the same site ──────────────── const MUST_NOT_MATCH = [ ['evil.github.io', 'victim.github.io', 'siblings on github.io'], ['attacker.vercel.app', 'realapp.vercel.app', 'siblings on vercel.app'], ['evil.herokuapp.com', 'real.herokuapp.com', 'siblings on herokuapp.com'], ['evil.co.uk', 'bank.co.uk', 'siblings under co.uk'], ['anything.com', 'com', 'item saved for a bare TLD'], ['login.evil.com', 'evil.com.attacker.net', 'suffix confusion'], ['example.com.evil.net', 'example.com', 'apex embedded in an attacker host'], ['192.168.1.10', '192.168.1.11', 'different IPs'], ['github.io', 'victim.github.io', 'bare suffix vs a site under it'], ]; for (const [a, b, label] of MUST_NOT_MATCH) { check(`isSameSite(${a}, ${b}) [${label}]`, PkPsl.isSameSite(a, b), false); } // ── Legitimate matches must keep working ──────────────────────────────────── const MUST_MATCH = [ ['login.example.com', 'example.com', 'subdomain to apex'], ['www.example.com', 'accounts.example.com', 'sibling subdomains'], ['a.b.c.example.co.uk', 'example.co.uk', 'deep subdomain under an ICANN suffix'], ['example.com', 'example.com', 'identical'], ['WWW.Example.COM', 'example.com', 'case-insensitive'], ['example.com.', 'example.com', 'trailing root dot'], ['localhost', 'localhost', 'localhost falls back to exact equality'], ['192.168.1.10', '192.168.1.10', 'IP falls back to exact equality'], ['github.io', 'github.io', 'bare suffix matches itself exactly'], ]; for (const [a, b, label] of MUST_MATCH) { check(`isSameSite(${a}, ${b}) [${label}]`, PkPsl.isSameSite(a, b), true); } // ── Malformed input must not throw ────────────────────────────────────────── for (const bad of [null, undefined, '', '.', '..', ' ', 'a..b']) { try { PkPsl.getRegistrableDomain(bad); PkPsl.isSameSite(bad, 'example.com'); } catch (e) { failures++; console.log(`FAIL threw on input ${JSON.stringify(bad)}: ${e.message}`); } } const total = MOZILLA_VECTORS.length + MUST_NOT_MATCH.length + MUST_MATCH.length + 3; if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); } console.log(`OK: ${total} PSL assertions passed`);