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:
@@ -23,14 +23,24 @@
|
||||
# X-Frame-Options and nosniff from every JS and CSS asset.
|
||||
|
||||
# ── Rate limiting zones ────────────────────────────────────────────────────────
|
||||
# NOTE the units: these are per MINUTE (r/m), not per second. api_limit is
|
||||
# therefore 1 req/s sustained for the whole API, with burst=20 absorbing spikes.
|
||||
# A vault page load fires several /api/* calls, so tightening these further will
|
||||
# surface as 429s to normal users.
|
||||
# auth_limit: 10 req/min per IP for auth endpoints (login, register, MFA verify)
|
||||
# api_limit: 60 req/min per IP for all other API endpoints
|
||||
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=60r/m;
|
||||
# MIND THE UNITS — r/s and r/m are easy to confuse, and getting it wrong is
|
||||
# invisible until users start seeing 429s.
|
||||
#
|
||||
# api_limit was 60r/m, i.e. 1 req/s sustained for the ENTIRE API. A single vault
|
||||
# page load fires several /api/* calls (vault, folders, sharing inbox, me), and
|
||||
# any active session drains the burst bucket quickly, so normal use produced
|
||||
# spurious 429s. Now 10r/s, which is generous for a human and still bounds
|
||||
# scripted abuse.
|
||||
#
|
||||
# auth_limit stays deliberately tight — it is the brute-force surface. 20r/m
|
||||
# with burst=5 is well above what a human retyping a password needs, and
|
||||
# Flask-Limiter (Redis-backed, 10/min on /login) remains the primary guard;
|
||||
# this zone exists to shed load before it reaches Gunicorn.
|
||||
#
|
||||
# auth_limit: 20 req/MINUTE per IP — login, register, MFA verify
|
||||
# api_limit: 10 req/SECOND per IP — everything else
|
||||
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=20r/m;
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
|
||||
server {
|
||||
server_name pwkeeper.ngodanguyen.tech passkeeper.ngodanguyen.tech;
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user