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
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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+10374
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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`);
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user