Aug 26 - Enhance security 3
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

This commit is contained in:
2026-08-26 13:54:48 -04:00
parent 6c1bef73c8
commit cc216b0d98
16 changed files with 11056 additions and 43 deletions
+7 -2
View File
@@ -8,7 +8,7 @@
# lint-python — flake8 style + error check
# syntax-check — ast.parse all Python files
# migration-check — verify Alembic chain has single head
# js-syntax — node syntax check on all JS files
# js-syntax — node syntax check on all JS files + PSL matching tests
# tests — pytest suite (in-memory SQLite, no MySQL needed)
# build-extension — zip Chrome and Firefox extensions
@@ -132,7 +132,7 @@ jobs:
extension/background.firefox.js \
extension/content/content.js \
extension/bridge/bridge.js \
extension/shared/crypto.js; do
extension/shared/crypto.js \n extension/shared/psl.js; do
if [ -f "$f" ]; then
node -e "new Function(require('fs').readFileSync('$f','utf8'))" 2>/dev/null || \
{ echo "FAIL: $f"; FAILED=1; }
@@ -141,6 +141,11 @@ jobs:
[ $FAILED -eq 0 ] && echo "OK: all JS files parsed cleanly"
exit $FAILED
- name: PSL matching tests
# Guards the autofill same-site check. A wrong answer here means
# credentials offered on an attacker's neighbouring subdomain.
run: node tests/js/test_psl.js
# ── Test suite ───────────────────────────────────────────────────────────────
# Runs against in-memory SQLite (see app/config.py TestingConfig) so no MySQL
# service is needed on the host-mode runner. That means these tests cover
+22 -2
View File
@@ -82,6 +82,7 @@ passkeeper/
│ ├── background.firefox.js # Firefox: in-memory session shim, setTimeout idle lock
│ ├── shared/
│ │ ├── crypto.js # PBKDF2+AES-GCM; encryptName/decryptName; extractable key
│ │ ├── psl.js # GENERATED — vendored Public Suffix List + PkPsl.isSameSite
│ │ └── browser-polyfill.js # chrome=browser alias for Firefox content scripts
│ ├── popup/
│ │ ├── popup.html # Tabs: All relevant / All items / Favorites / Recents
@@ -106,6 +107,7 @@ passkeeper/
│ ├── g7h8i9j0k1l2_encrypt_shared_item_name.py # enc_name/iv_name on shared_items
│ └── h8i9j0k1l2m3_add_webauthn_credentials_table.py # Passkey / WebAuthn credentials
├── scripts/
│ ├── update_psl.py # regenerates extension/shared/psl.js
│ ├── reencrypt_totp_secrets.py
│ ├── backup_db.sh / backup.cron / passkeeper-logrotate
│ ├── passkeeper-nginx.conf / passkeeper.service
@@ -115,7 +117,9 @@ passkeeper/
│ ├── test_key_rotation.py # re-encryption completeness guard
│ ├── test_session_revocation.py # token_epoch revocation; deleted-account 401
│ ├── test_webauthn_uv.py # user verification required on both ceremonies
── test_deploy_config.py # nginx/gunicorn/systemd invariants (502 guards)
── test_sharing_expiry.py # expires_days fails closed
│ ├── test_deploy_config.py # nginx/gunicorn/systemd/extension packaging guards
│ └── js/test_psl.js # PSL same-site matching (node, run in CI)
├── gunicorn.conf.py # worker class, timeouts, preload_app=False
├── pytest.ini
├── requirements-dev.txt
@@ -264,6 +268,15 @@ CREATE TABLE webauthn_credentials (
- **MFA:** TOTP secret AES-256-GCM encrypted at rest; each code is single-use (replay prevented via `totp_used_codes` table, 120s TTL)
- **Passkeys / WebAuthn:** server authentication via FIDO2; ZK model preserved — WebAuthn proves identity to the server but the vault key is still derived from the master password client-side; `sign_count` updated on every assertion for clone detection
- **Sharing:** ECDH P-256 zero-knowledge re-encryption; item name also encrypted with shared key
- **Autofill same-site rule:** `PkPsl.isSameSite()` compares *registrable
domains* using the vendored Public Suffix List — never suffix comparison.
`host.endsWith("." + h)` treated `evil.github.io` and `victim.github.io` as
the same site and let an item saved for a bare TLD match everything under it.
Used identically by `content.js`, `popup.js`, `background.js` and
`background.firefox.js`; all four fall back to exact hostname equality if
`psl.js` fails to load (strict, so a failure loses matches rather than
leaking credentials). The PRIVATE section of the list is required — that is
where `github.io` / `vercel.app` / `herokuapp.com` live.
- **Password generator:** fully CSPRNG (`_cryptoRandInt` rejection-sampling)
- **Decrypted vault data:** `chrome.storage.session` only — never to disk
- **Clipboard auto-clear:** 30 s after any password/username copy (web + extension)
@@ -630,6 +643,9 @@ Audit log details **never** contain plaintext item names, shared item names, or
- WebAuthn `attachment`: `"cross-platform"` for security keys; `"platform"` for device biometrics (default)
- `enc_vault_is_legacy` check in `EmergencyAccess.to_dict()` is pure JSON inspection — no decryption
- Never return `str(e)` from exception handlers — log with `_log.exception(...)` and return a generic user-facing message to avoid leaking DB schema details or query fragments
- `extension/shared/psl.js` is GENERATED — never hand-edit; run `python scripts/update_psl.py`. It must load BEFORE content.js / popup.js / background.js in every manifest
- Never reintroduce `endsWith("." + host)` host matching anywhere in the extension — `tests/test_deploy_config.py` fails the build if it reappears
- nginx rate zones: mind `r/s` vs `r/m`. `api_limit` was `60r/m` (1 req/s for the whole API) and caused spurious 429s on normal vault use
- `preload_app` must stay `False` in `gunicorn.conf.py` — APScheduler's thread does not survive `fork()`, so `--preload` silently disables the cleanup job
- nginx `proxy_read_timeout` must stay BELOW gunicorn `timeout`, else a slow request returns 502 instead of 504
- `WatchdogSec` in the systemd unit requires `Type=notify` + `NotifyAccess=main`; without them systemd SIGKILLs the service on a loop
@@ -838,7 +854,11 @@ Features planned for future implementation. Ordered by priority within each cate
- Key-rotation completeness guard on `change_password` / `/recover`
- Session revocation via `token_epoch`; `require_jwt` now verifies the user exists
- Passkey ceremonies require user verification
- pytest suite (37 tests) + CI job; `gunicorn.conf.py`; systemd watchdog removed
- Autofill matching moved onto the Public Suffix List (registrable domains)
- Share `expires_days` fails closed instead of silently meaning "never"
- nginx `api_limit` corrected from 60r/m to 10r/s
- pytest suite (51 tests) + PSL node test + CI jobs; `gunicorn.conf.py`;
systemd watchdog removed
### High priority — user-facing
+11 -2
View File
@@ -131,19 +131,28 @@ def create_share():
iv_name = data.get('iv_name') or None
# Optional expiry: number of days until the share expires (None = never).
# Accepted values: 1, 7, 30, 90, None.
# Fail closed: a value we cannot parse must be an error, not "never
# expires". The previous `except: pass` meant a typo or a client bug
# silently produced a permanent share — the opposite of what was asked for.
expires_days = data.get('expires_days')
expires_at = None
if expires_days is not None:
try:
expires_days = int(expires_days)
except (TypeError, ValueError):
return jsonify({
'error': 'expires_days must be an integer number of days, or null for no expiry'
}), 400
if expires_days < 0 or expires_days > 3650:
return jsonify({
'error': 'expires_days must be between 0 and 3650'
}), 400
if expires_days > 0:
from datetime import timedelta
expires_at = (
datetime.now(timezone.utc).replace(tzinfo=None)
+ timedelta(days=expires_days)
)
except (TypeError, ValueError):
pass
if not all([item_id, recipient_email, enc_data, iv, item_name]):
return jsonify({'error': 'item_id, recipient_email, enc_data, iv, item_name are required'}), 400
+10 -3
View File
@@ -113,14 +113,21 @@ async function updateBadgeForTab(tabId, url) {
return;
}
let hostname;
try { hostname = new URL(url).hostname.replace(/^www\./, ''); } catch {
try { hostname = new URL(url).hostname; } catch {
chrome.browserAction.setBadgeText({ text: '', tabId }); return;
}
// Registrable-domain comparison via the vendored PSL (loaded ahead of this
// file by manifest.firefox.json background.scripts), matching content.js
// and popup.js. Falls back to exact equality so a load failure undercounts
// rather than counting an attacker's neighbouring subdomain.
const sameSite =
typeof PkPsl !== 'undefined' && PkPsl?.isSameSite
? PkPsl.isSameSite
: (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
const matches = vault_items.filter((item) => {
if (item.item_type !== 'password' || !item.plain?.url) return false;
try {
const h = new URL(item.plain.url).hostname.replace(/^www\./, '');
return h === hostname || h.endsWith(`.${hostname}`) || hostname.endsWith(`.${h}`);
return sameSite(new URL(item.plain.url).hostname, hostname);
} catch { return false; }
});
if (matches.length > 0) {
+16 -7
View File
@@ -8,6 +8,12 @@
* - Lock the vault automatically after IDLE_LOCK_SECONDS of system inactivity.
*/
// Public Suffix List — the badge counts matching items, and must use the same
// same-site rule as content.js and popup.js. Counting a match on an attacker's
// neighbouring subdomain is itself a misleading signal, even though the badge
// alone does not disclose a credential.
importScripts("shared/psl.js");
// ── Idle lock ─────────────────────────────────────────────────────────────────
// Default: never lock (session clears naturally on browser close via chrome.storage.session).
@@ -119,21 +125,24 @@ async function updateBadgeForTab(tabId, url) {
let hostname;
try {
hostname = new URL(url).hostname.replace(/^www\./, "");
hostname = new URL(url).hostname;
} catch {
chrome.action.setBadgeText({ text: "", tabId });
return;
}
// Registrable-domain comparison, matching content.js and popup.js. Falls
// back to exact equality if psl.js is unavailable — strict, so a load
// failure undercounts rather than counting an attacker's subdomain.
const sameSite =
typeof PkPsl !== "undefined" && PkPsl?.isSameSite
? PkPsl.isSameSite
: (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
const matches = vault_items.filter((item) => {
if (item.item_type !== "password" || !item.plain?.url) return false;
try {
const h = new URL(item.plain.url).hostname.replace(/^www\./, "");
return (
h === hostname ||
h.endsWith(`.${hostname}`) ||
hostname.endsWith(`.${h}`)
);
return sameSite(new URL(item.plain.url).hostname, hostname);
} catch {
return false;
}
+26 -4
View File
@@ -922,17 +922,39 @@
return "https://" + s; // bare domain or path
}
/**
* Select the stored items whose URL belongs to the page we are on.
*
* Matching is by registrable domain (PkPsl.isSameSite), NOT by suffix
* comparison. The previous test was:
*
* h === host || h.endsWith("." + host) || host.endsWith("." + h)
*
* which had no notion of a public suffix, so a credential saved for
* victim.github.io was offered on evil.github.io, and one saved for a bare
* TLD was offered everywhere under it. Surfacing a match on an attacker's
* neighbouring subdomain defeats the phishing resistance that is most of the
* point of a password manager.
*
* If psl.js somehow failed to load we fall back to exact hostname equality
* strict, so a load failure loses matches rather than leaking credentials.
*/
function _filterForHost(items) {
var host = location.hostname.replace(/^www\./, "");
var host = location.hostname;
var sameSite =
typeof PkPsl !== "undefined" && PkPsl && PkPsl.isSameSite
? PkPsl.isSameSite
: function (a, b) {
return String(a).toLowerCase() === String(b).toLowerCase();
};
return (items || []).filter(function (item) {
if (item.item_type !== "password" || !(item.plain && item.plain.url))
return false;
try {
var normalised = _normaliseUrl(item.plain.url);
if (!normalised) return false;
var h = new URL(normalised).hostname.replace(/^www\./, "");
// Match exact domain or any subdomain relationship.
return h === host || h.endsWith("." + host) || host.endsWith("." + h);
return sameSite(new URL(normalised).hostname, host);
} catch (e) {
console.warn(
"[PassKeeper] _filterForHost: could not parse URL:",
+20 -5
View File
@@ -21,18 +21,33 @@
}
},
"background": {
"scripts": ["background.firefox.js"],
"scripts": [
"shared/psl.js",
"background.firefox.js"
],
"persistent": false
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["shared/browser-polyfill.js", "content/content.js"],
"matches": [
"http://*/*",
"https://*/*"
],
"js": [
"shared/browser-polyfill.js",
"shared/psl.js",
"content/content.js"
],
"run_at": "document_idle"
},
{
"matches": ["https://pwkeeper.ngodanguyen.tech/*"],
"js": ["shared/browser-polyfill.js", "bridge/bridge.js"],
"matches": [
"https://pwkeeper.ngodanguyen.tech/*"
],
"js": [
"shared/browser-polyfill.js",
"bridge/bridge.js"
],
"run_at": "document_idle"
}
],
+4 -1
View File
@@ -32,6 +32,7 @@
"https://*/*"
],
"js": [
"shared/psl.js",
"content/content.js"
],
"run_at": "document_idle"
@@ -49,7 +50,9 @@
"web_accessible_resources": [
{
"resources": [],
"matches": ["<all_urls>"]
"matches": [
"<all_urls>"
]
}
],
"icons": {
+3
View File
@@ -621,6 +621,9 @@
</nav>
</div>
<!-- /app -->
<!-- Public Suffix List — must load before popup.js, which calls PkPsl
from isMatch() to decide which stored items belong to the active tab. -->
<script src="../shared/psl.js"></script>
<script src="../shared/crypto.js"></script>
<script src="../shared/sharing-crypto.js"></script>
<script src="popup.js"></script>
+16 -3
View File
@@ -220,15 +220,28 @@ function _normaliseUrl(raw) {
return "https://" + s;
}
/**
* Does this stored item belong to the site in the active tab?
*
* Compared by registrable domain (PkPsl.isSameSite), not by suffix. The old
* test had no notion of a public suffix, so victim.github.io matched
* evil.github.io and an item saved for a bare TLD matched every site under it.
* Must stay in step with _filterForHost() in content/content.js.
*
* Falls back to exact hostname equality if psl.js failed to load strict, so a
* load failure loses matches rather than leaking credentials.
*/
function isMatch(item) {
const host = currentHostname();
if (!host || item.item_type !== "password" || !item.plain?.url) return false;
try {
const normalised = _normaliseUrl(item.plain.url);
if (!normalised) return false;
const h = new URL(normalised).hostname.replace(/^www\./, "");
// Match exact domain or any subdomain relationship.
return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`);
const itemHost = new URL(normalised).hostname;
if (typeof PkPsl !== "undefined" && PkPsl?.isSameSite) {
return PkPsl.isSameSite(itemHost, host);
}
return itemHost.toLowerCase() === String(host).toLowerCase();
} catch {
return false;
}
File diff suppressed because it is too large Load Diff
+18 -8
View File
@@ -23,14 +23,24 @@
# X-Frame-Options and nosniff from every JS and CSS asset.
# ── Rate limiting zones ────────────────────────────────────────────────────────
# NOTE the units: these are per MINUTE (r/m), not per second. api_limit is
# therefore 1 req/s sustained for the whole API, with burst=20 absorbing spikes.
# A vault page load fires several /api/* calls, so tightening these further will
# surface as 429s to normal users.
# auth_limit: 10 req/min per IP for auth endpoints (login, register, MFA verify)
# api_limit: 60 req/min per IP for all other API endpoints
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=60r/m;
# MIND THE UNITS — r/s and r/m are easy to confuse, and getting it wrong is
# invisible until users start seeing 429s.
#
# api_limit was 60r/m, i.e. 1 req/s sustained for the ENTIRE API. A single vault
# page load fires several /api/* calls (vault, folders, sharing inbox, me), and
# any active session drains the burst bucket quickly, so normal use produced
# spurious 429s. Now 10r/s, which is generous for a human and still bounds
# scripted abuse.
#
# auth_limit stays deliberately tight — it is the brute-force surface. 20r/m
# with burst=5 is well above what a human retyping a password needs, and
# Flask-Limiter (Redis-backed, 10/min on /login) remains the primary guard;
# this zone exists to shed load before it reaches Gunicorn.
#
# auth_limit: 20 req/MINUTE per IP — login, register, MFA verify
# api_limit: 10 req/SECOND per IP — everything else
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=20r/m;
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
server {
server_name pwkeeper.ngodanguyen.tech passkeeper.ngodanguyen.tech;
+212
View File
@@ -0,0 +1,212 @@
#!/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()
+127
View File
@@ -0,0 +1,127 @@
/**
* 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`);
+97
View File
@@ -107,3 +107,100 @@ def test_unit_loads_the_gunicorn_config_file():
assert 'gunicorn.conf.py' in UNIT, (
'the unit no longer references gunicorn.conf.py, so its tuning is dead code'
)
def test_api_rate_limit_is_not_absurdly_tight():
"""
api_limit was 60r/m 1 req/s for the entire API. A vault page load fires
several /api/* calls, so normal use produced spurious 429s. The units are
easy to misread, which is exactly why this is pinned.
"""
m = re.search(r'zone=api_limit:\S+\s+rate=(\d+)r/([sm]);', NGINX)
assert m, 'api_limit zone not found'
per_second = int(m.group(1)) / (1 if m.group(2) == 's' else 60)
assert per_second >= 5, (
f'api_limit is {per_second:.2f} req/s — too tight for normal vault use'
)
def test_auth_rate_limit_stays_tight():
"""The brute-force surface must NOT be widened along with api_limit."""
m = re.search(r'zone=auth_limit:\S+\s+rate=(\d+)r/([sm]);', NGINX)
assert m, 'auth_limit zone not found'
per_minute = int(m.group(1)) * (60 if m.group(2) == 's' else 1)
assert per_minute <= 60, f'auth_limit is {per_minute} req/min — too permissive'
# -- Extension packaging -----------------------------------------------------
EXT = ROOT / 'extension'
@pytest.mark.parametrize('manifest_name',
['manifest.json', 'manifest.firefox.json'])
def test_manifest_loads_psl_before_content_script(manifest_name):
"""
content.js calls PkPsl at match time. If psl.js is missing from the manifest
the matcher silently falls back to exact-hostname equality, quietly losing
every subdomain match.
"""
import json
manifest = json.loads((EXT / manifest_name).read_text(encoding='utf-8'))
blocks = [cs for cs in manifest.get('content_scripts', [])
if any('content/content.js' in f for f in cs['js'])]
assert blocks, f'{manifest_name} has no content.js block'
for cs in blocks:
assert 'shared/psl.js' in cs['js'], f'{manifest_name}: psl.js not loaded'
assert cs['js'].index('shared/psl.js') < cs['js'].index('content/content.js'), \
f'{manifest_name}: psl.js must load BEFORE content.js'
def test_popup_html_loads_psl():
# Compare the parsed <script src> order, not raw string positions — the
# surrounding comments mention these filenames too.
html = (EXT / 'popup' / 'popup.html').read_text(encoding='utf-8')
srcs = re.findall(r'<script src="([^"]+)"', html)
assert '../shared/psl.js' in srcs, 'popup.html does not load psl.js'
assert srcs.index('../shared/psl.js') < srcs.index('popup.js'), \
f'psl.js must load before popup.js, got {srcs}'
def test_psl_includes_the_private_section():
"""
The PRIVATE section (github.io, vercel.app, herokuapp.com) is where an
attacker can actually register a neighbouring subdomain. Regenerating the
list with only the ICANN section would silently reopen finding #5.
"""
psl = (EXT / 'shared' / 'psl.js').read_text(encoding='utf-8')
for suffix in ('github.io', 'vercel.app', 'herokuapp.com'):
assert f'\n{suffix}\n' in psl, f'PSL is missing the private suffix {suffix}'
def test_no_suffix_matching_remains_in_the_extension():
"""
The endsWith("." + host) pattern is the finding-#5 bug. If it reappears,
credentials are being offered across public-suffix boundaries again.
"""
for rel in ('content/content.js', 'popup/popup.js',
'background.js', 'background.firefox.js'):
src = (EXT / rel).read_text(encoding='utf-8')
code = '\n'.join(ln for ln in src.splitlines()
if not ln.strip().startswith(('*', '//', '/*')))
for pattern in ('endsWith("." + host', 'endsWith(`.${host',
'endsWith(`.${h}`)', "endsWith('.' + host"):
assert pattern not in code, f'{rel}: suffix matching is back ({pattern})'
def test_background_scripts_load_psl():
"""
The badge counts matching items and must use the same same-site rule.
Chrome pulls psl.js in via importScripts; Firefox via background.scripts.
"""
import json
mv3 = (EXT / 'background.js').read_text(encoding='utf-8')
assert 'importScripts("shared/psl.js")' in mv3, 'background.js does not importScripts psl.js'
ff = json.loads((EXT / 'manifest.firefox.json').read_text(encoding='utf-8'))
scripts = ff['background']['scripts']
assert 'shared/psl.js' in scripts, 'firefox background does not load psl.js'
assert scripts.index('shared/psl.js') < scripts.index('background.firefox.js'), 'psl.js must load before background.firefox.js'
+87
View File
@@ -0,0 +1,87 @@
"""
Regression tests for share expiry failing open (review finding #10).
create_share parsed expires_days inside `try: ... except: pass`, so any value it
could not parse silently became "never expires" the opposite of what the user
asked for, with no error to notice.
"""
from datetime import datetime, timedelta, timezone
from tests.conftest import add_item, auth_headers, make_user
def _share(client, token, item_id, recipient='friend@example.com', **extra):
body = {
'item_id': item_id,
'recipient_email': recipient,
'enc_data': 'ECDH-CT',
'iv': 'ECDH-IV',
'item_name': 'password',
'item_type': 'password',
}
body.update(extra)
return client.post('/api/sharing', headers=auth_headers(token), json=body)
def test_valid_expiry_is_applied(client, app):
token, _ = make_user(client)
item_id = add_item(client, token)
res = _share(client, token, item_id, expires_days=7)
assert res.status_code == 201, res.get_json()
expires_at = res.get_json()['expires_at']
assert expires_at is not None
parsed = datetime.fromisoformat(expires_at)
expected = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(days=7)
assert abs((parsed - expected).total_seconds()) < 60
def test_null_expiry_means_never(client, app):
token, _ = make_user(client)
item_id = add_item(client, token)
res = _share(client, token, item_id, expires_days=None)
assert res.status_code == 201
assert res.get_json()['expires_at'] is None
def test_omitted_expiry_means_never(client, app):
token, _ = make_user(client)
item_id = add_item(client, token)
res = _share(client, token, item_id)
assert res.status_code == 201
assert res.get_json()['expires_at'] is None
def test_unparseable_expiry_is_rejected_not_silently_dropped(client, app):
"""The bug: 'seven' used to yield a share that never expires."""
token, _ = make_user(client)
for bad in ('seven', '7 days', {}, [], 'NaN', ''):
item_id = add_item(client, token)
res = _share(client, token, item_id, expires_days=bad)
assert res.status_code == 400, (
f'expires_days={bad!r} accepted; share would never expire'
)
assert 'expires_days' in res.get_json()['error']
def test_out_of_range_expiry_is_rejected(client, app):
token, _ = make_user(client)
for bad in (-1, -30, 4000):
item_id = add_item(client, token)
res = _share(client, token, item_id, expires_days=bad)
assert res.status_code == 400, f'expires_days={bad!r} accepted'
def test_zero_expiry_means_never(client, app):
"""0 is 'no expiry', consistent with null — not 'expires immediately'."""
token, _ = make_user(client)
item_id = add_item(client, token)
res = _share(client, token, item_id, expires_days=0)
assert res.status_code == 201
assert res.get_json()['expires_at'] is None