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 # lint-python — flake8 style + error check
# syntax-check — ast.parse all Python files # syntax-check — ast.parse all Python files
# migration-check — verify Alembic chain has single head # 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) # tests — pytest suite (in-memory SQLite, no MySQL needed)
# build-extension — zip Chrome and Firefox extensions # build-extension — zip Chrome and Firefox extensions
@@ -132,7 +132,7 @@ jobs:
extension/background.firefox.js \ extension/background.firefox.js \
extension/content/content.js \ extension/content/content.js \
extension/bridge/bridge.js \ extension/bridge/bridge.js \
extension/shared/crypto.js; do extension/shared/crypto.js \n extension/shared/psl.js; do
if [ -f "$f" ]; then if [ -f "$f" ]; then
node -e "new Function(require('fs').readFileSync('$f','utf8'))" 2>/dev/null || \ node -e "new Function(require('fs').readFileSync('$f','utf8'))" 2>/dev/null || \
{ echo "FAIL: $f"; FAILED=1; } { echo "FAIL: $f"; FAILED=1; }
@@ -141,6 +141,11 @@ jobs:
[ $FAILED -eq 0 ] && echo "OK: all JS files parsed cleanly" [ $FAILED -eq 0 ] && echo "OK: all JS files parsed cleanly"
exit $FAILED 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 ─────────────────────────────────────────────────────────────── # ── Test suite ───────────────────────────────────────────────────────────────
# Runs against in-memory SQLite (see app/config.py TestingConfig) so no MySQL # 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 # 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 │ ├── background.firefox.js # Firefox: in-memory session shim, setTimeout idle lock
│ ├── shared/ │ ├── shared/
│ │ ├── crypto.js # PBKDF2+AES-GCM; encryptName/decryptName; extractable key │ │ ├── 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 │ │ └── browser-polyfill.js # chrome=browser alias for Firefox content scripts
│ ├── popup/ │ ├── popup/
│ │ ├── popup.html # Tabs: All relevant / All items / Favorites / Recents │ │ ├── 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 │ ├── g7h8i9j0k1l2_encrypt_shared_item_name.py # enc_name/iv_name on shared_items
│ └── h8i9j0k1l2m3_add_webauthn_credentials_table.py # Passkey / WebAuthn credentials │ └── h8i9j0k1l2m3_add_webauthn_credentials_table.py # Passkey / WebAuthn credentials
├── scripts/ ├── scripts/
│ ├── update_psl.py # regenerates extension/shared/psl.js
│ ├── reencrypt_totp_secrets.py │ ├── reencrypt_totp_secrets.py
│ ├── backup_db.sh / backup.cron / passkeeper-logrotate │ ├── backup_db.sh / backup.cron / passkeeper-logrotate
│ ├── passkeeper-nginx.conf / passkeeper.service │ ├── passkeeper-nginx.conf / passkeeper.service
@@ -115,7 +117,9 @@ passkeeper/
│ ├── test_key_rotation.py # re-encryption completeness guard │ ├── test_key_rotation.py # re-encryption completeness guard
│ ├── test_session_revocation.py # token_epoch revocation; deleted-account 401 │ ├── test_session_revocation.py # token_epoch revocation; deleted-account 401
│ ├── test_webauthn_uv.py # user verification required on both ceremonies │ ├── 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 ├── gunicorn.conf.py # worker class, timeouts, preload_app=False
├── pytest.ini ├── pytest.ini
├── requirements-dev.txt ├── 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) - **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 - **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 - **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) - **Password generator:** fully CSPRNG (`_cryptoRandInt` rejection-sampling)
- **Decrypted vault data:** `chrome.storage.session` only — never to disk - **Decrypted vault data:** `chrome.storage.session` only — never to disk
- **Clipboard auto-clear:** 30 s after any password/username copy (web + extension) - **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) - 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 - `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 - 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 - `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 - 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 - `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` - Key-rotation completeness guard on `change_password` / `/recover`
- Session revocation via `token_epoch`; `require_jwt` now verifies the user exists - Session revocation via `token_epoch`; `require_jwt` now verifies the user exists
- Passkey ceremonies require user verification - 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 ### High priority — user-facing
+11 -2
View File
@@ -131,19 +131,28 @@ def create_share():
iv_name = data.get('iv_name') or None iv_name = data.get('iv_name') or None
# Optional expiry: number of days until the share expires (None = never). # Optional expiry: number of days until the share expires (None = never).
# Accepted values: 1, 7, 30, 90, None. # 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_days = data.get('expires_days')
expires_at = None expires_at = None
if expires_days is not None: if expires_days is not None:
try: try:
expires_days = int(expires_days) 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: if expires_days > 0:
from datetime import timedelta from datetime import timedelta
expires_at = ( expires_at = (
datetime.now(timezone.utc).replace(tzinfo=None) datetime.now(timezone.utc).replace(tzinfo=None)
+ timedelta(days=expires_days) + timedelta(days=expires_days)
) )
except (TypeError, ValueError):
pass
if not all([item_id, recipient_email, enc_data, iv, item_name]): 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 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; return;
} }
let hostname; let hostname;
try { hostname = new URL(url).hostname.replace(/^www\./, ''); } catch { try { hostname = new URL(url).hostname; } catch {
chrome.browserAction.setBadgeText({ text: '', tabId }); return; 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) => { const matches = vault_items.filter((item) => {
if (item.item_type !== 'password' || !item.plain?.url) return false; if (item.item_type !== 'password' || !item.plain?.url) return false;
try { try {
const h = new URL(item.plain.url).hostname.replace(/^www\./, ''); return sameSite(new URL(item.plain.url).hostname, hostname);
return h === hostname || h.endsWith(`.${hostname}`) || hostname.endsWith(`.${h}`);
} catch { return false; } } catch { return false; }
}); });
if (matches.length > 0) { if (matches.length > 0) {
+16 -7
View File
@@ -8,6 +8,12 @@
* - Lock the vault automatically after IDLE_LOCK_SECONDS of system inactivity. * - 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 ───────────────────────────────────────────────────────────────── // ── Idle lock ─────────────────────────────────────────────────────────────────
// Default: never lock (session clears naturally on browser close via chrome.storage.session). // Default: never lock (session clears naturally on browser close via chrome.storage.session).
@@ -119,21 +125,24 @@ async function updateBadgeForTab(tabId, url) {
let hostname; let hostname;
try { try {
hostname = new URL(url).hostname.replace(/^www\./, ""); hostname = new URL(url).hostname;
} catch { } catch {
chrome.action.setBadgeText({ text: "", tabId }); chrome.action.setBadgeText({ text: "", tabId });
return; 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) => { const matches = vault_items.filter((item) => {
if (item.item_type !== "password" || !item.plain?.url) return false; if (item.item_type !== "password" || !item.plain?.url) return false;
try { try {
const h = new URL(item.plain.url).hostname.replace(/^www\./, ""); return sameSite(new URL(item.plain.url).hostname, hostname);
return (
h === hostname ||
h.endsWith(`.${hostname}`) ||
hostname.endsWith(`.${h}`)
);
} catch { } catch {
return false; return false;
} }
+26 -4
View File
@@ -922,17 +922,39 @@
return "https://" + s; // bare domain or path 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) { 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) { return (items || []).filter(function (item) {
if (item.item_type !== "password" || !(item.plain && item.plain.url)) if (item.item_type !== "password" || !(item.plain && item.plain.url))
return false; return false;
try { try {
var normalised = _normaliseUrl(item.plain.url); var normalised = _normaliseUrl(item.plain.url);
if (!normalised) return false; if (!normalised) return false;
var h = new URL(normalised).hostname.replace(/^www\./, ""); return sameSite(new URL(normalised).hostname, host);
// Match exact domain or any subdomain relationship.
return h === host || h.endsWith("." + host) || host.endsWith("." + h);
} catch (e) { } catch (e) {
console.warn( console.warn(
"[PassKeeper] _filterForHost: could not parse URL:", "[PassKeeper] _filterForHost: could not parse URL:",
+20 -5
View File
@@ -21,18 +21,33 @@
} }
}, },
"background": { "background": {
"scripts": ["background.firefox.js"], "scripts": [
"shared/psl.js",
"background.firefox.js"
],
"persistent": false "persistent": false
}, },
"content_scripts": [ "content_scripts": [
{ {
"matches": ["http://*/*", "https://*/*"], "matches": [
"js": ["shared/browser-polyfill.js", "content/content.js"], "http://*/*",
"https://*/*"
],
"js": [
"shared/browser-polyfill.js",
"shared/psl.js",
"content/content.js"
],
"run_at": "document_idle" "run_at": "document_idle"
}, },
{ {
"matches": ["https://pwkeeper.ngodanguyen.tech/*"], "matches": [
"js": ["shared/browser-polyfill.js", "bridge/bridge.js"], "https://pwkeeper.ngodanguyen.tech/*"
],
"js": [
"shared/browser-polyfill.js",
"bridge/bridge.js"
],
"run_at": "document_idle" "run_at": "document_idle"
} }
], ],
+4 -1
View File
@@ -32,6 +32,7 @@
"https://*/*" "https://*/*"
], ],
"js": [ "js": [
"shared/psl.js",
"content/content.js" "content/content.js"
], ],
"run_at": "document_idle" "run_at": "document_idle"
@@ -49,7 +50,9 @@
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": [], "resources": [],
"matches": ["<all_urls>"] "matches": [
"<all_urls>"
]
} }
], ],
"icons": { "icons": {
+3
View File
@@ -621,6 +621,9 @@
</nav> </nav>
</div> </div>
<!-- /app --> <!-- /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/crypto.js"></script>
<script src="../shared/sharing-crypto.js"></script> <script src="../shared/sharing-crypto.js"></script>
<script src="popup.js"></script> <script src="popup.js"></script>
+16 -3
View File
@@ -220,15 +220,28 @@ function _normaliseUrl(raw) {
return "https://" + s; 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) { function isMatch(item) {
const host = currentHostname(); const host = currentHostname();
if (!host || item.item_type !== "password" || !item.plain?.url) return false; if (!host || item.item_type !== "password" || !item.plain?.url) return false;
try { try {
const normalised = _normaliseUrl(item.plain.url); const normalised = _normaliseUrl(item.plain.url);
if (!normalised) return false; if (!normalised) return false;
const h = new URL(normalised).hostname.replace(/^www\./, ""); const itemHost = new URL(normalised).hostname;
// Match exact domain or any subdomain relationship. if (typeof PkPsl !== "undefined" && PkPsl?.isSameSite) {
return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`); return PkPsl.isSameSite(itemHost, host);
}
return itemHost.toLowerCase() === String(host).toLowerCase();
} catch { } catch {
return false; 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. # X-Frame-Options and nosniff from every JS and CSS asset.
# ── Rate limiting zones ──────────────────────────────────────────────────────── # ── Rate limiting zones ────────────────────────────────────────────────────────
# NOTE the units: these are per MINUTE (r/m), not per second. api_limit is # MIND THE UNITS — r/s and r/m are easy to confuse, and getting it wrong is
# therefore 1 req/s sustained for the whole API, with burst=20 absorbing spikes. # invisible until users start seeing 429s.
# A vault page load fires several /api/* calls, so tightening these further will #
# surface as 429s to normal users. # api_limit was 60r/m, i.e. 1 req/s sustained for the ENTIRE API. A single vault
# auth_limit: 10 req/min per IP for auth endpoints (login, register, MFA verify) # page load fires several /api/* calls (vault, folders, sharing inbox, me), and
# api_limit: 60 req/min per IP for all other API endpoints # any active session drains the burst bucket quickly, so normal use produced
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m; # spurious 429s. Now 10r/s, which is generous for a human and still bounds
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=60r/m; # 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 {
server_name pwkeeper.ngodanguyen.tech passkeeper.ngodanguyen.tech; 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, ( assert 'gunicorn.conf.py' in UNIT, (
'the unit no longer references gunicorn.conf.py, so its tuning is dead code' '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