Skip to content

Latest commit

 

History

History
356 lines (303 loc) · 120 KB

control-mapping.md

File metadata and controls

356 lines (303 loc) · 120 KB

Countersign: security control mapping

Reviewer: Raymond (security). Code state reviewed: 2026-11-02, after two fix rounds (H1, M1-M4, L1-L6, then N1-N3, L7, L8). Line numbers refer to that state. Tier 1 (seal, signer email code, expiry, self-sign, new field types) reviewed the same day and re-tested after the T1 fix round; see section 0b and findings T1-1 to T1-10. Tier 2 (contacts, CSV import, saved signatures, public verify, redirect after signing, email templates) reviewed the same day; see section 0c and findings T2-1 to T2-9. The T2 fix round (T2-1, T2-2, T2-3, T2-4, T2-7, T2-9) was re-tested in two full-suite runs on 2026-11-02 against fresh servers: run A (SMTP_HOST, no APP_URL) 156 tests, 147 pass, 0 fail, 9 skipped; run B (SMTP and APP_URL, mail sink) 156 tests, 153 pass, 0 fail, 3 skipped. test/security/tier2.test.mjs had no failures in either run. Tier 3 wave 1 (API keys, webhooks, folders, bulk send, public template link) reviewed 2026-11-03; see section 0d and findings T3-01 to T3-10. The 2026-11-03 fix round closed T3-02, T3-03, T3-04, T3-05, T3-07, T3-08 and T3-10 and part of T3-01. The orchestrator re-ran the full suite against the fixed build: run A (email off) 189 pass, 1 fail (flaky, F-1), 17 skipped; run B (email on) 202 pass, 0 fail, 5 skipped. Tier 3 wave 2 (teams) reviewed 2026-11-03 on the uncommitted wave 2 build; see section 0e and findings TW2-1 to TW2-8. The TW2 fix round (2026-11-03) closed TW2-1 to TW2-5; the orchestrator's teams-closed run of test/teams.test.mjs and test/security/teams.test.mjs gave 31 tests, 31 pass. OpenID Connect single sign-on (uncommitted build on top of 9aba916) and the commits 98d8ed4..9aba916 reviewed 2026-11-04; see section 0f and findings SSO-1 to SSO-16.

What this is. ISO/IEC 27001, SOC 1/SOC 2 (SSAE 18), CSA STAR, PCI DSS and APEC PRP certify or attest an organisation's controls. A program can't be certified against them. This document maps the controls that self-hosted software can provide to the clauses an operator's auditor will test, and lists what is missing. It is not a compliance claim. Countersign is not certified, attested or registered under any of these schemes.

Status key: Met = control implemented in code and exercised by a regression test against a disposable server (not independently audited). Partial = some of it exists, or it exists with a known weakness listed in the same row. Gap = missing. Operator = outside the software.

Evidence: test/security/p0.test.mjs and test/security/web.test.mjs. On 2026-11-02 they ran against a fresh server with an explicit ENCRYPTION_KEY and DATA_DIR/SERVER_LOG checks enabled: 35 pass, 0 fail, 1 skipped (no SMTP sink), 0 todo. Restart-time behaviour (migration marker, key check at boot) was checked by hand. It is described in section 0 and is not covered by an automated test.

0. What the controls do and don't prove

  • Encryption at rest covers PDF files, fields.value, the owner's copy of signing tokens, and tombstone event lists (lib/crypto.js:51-79, lib/documents.js:22-23,40-41,47). The rest of the SQLite database is plaintext: titles, messages, names, emails, IPs, audit events and security events. Confidentiality depends on where the key lives. With no ENCRYPTION_KEY, the key sits in DATA_DIR/encryption.key (lib/crypto.js:29-45), so anyone with a copy of the data volume can decrypt everything.
  • File integrity.
    • Decryption is strict: anything that isn't CSE1 ciphertext throws (lib/crypto.js:61-62,75-77).
    • Every PDF read is checked against the SHA-256 recorded in the DB: owner download, signer download, stamping and template copy (lib/documents.js:26-35). A mismatch is a 500 plus a file_integrity_failure security event.
    • Legacy plaintext is encrypted only on the first open of pre-encryption data: when there is no encryption_migrated marker, no key_check and no CSE1 file (lib/db.js:132-141). Verified by hand: after deleting the marker and rewriting original_sha256, a planted plaintext file is not re-encrypted on restart (N3 closed).
    • Limit: if that very first migration is interrupted part-way, the next start sees CSE1 files and skips the legacy pass. The PDFs still in plaintext then fail closed (500) until an operator re-runs the migration by hand. Nothing is lost or laundered, but the documents are unavailable.
  • Audit chain.
    • Each link is HMAC-SHA256 under a subkey derived with HKDF from the encryption key (lib/crypto.js:105-118).
    • A DB writer without the key can no longer forge or rewrite the chain. Verified: a full chain recomputed with plain SHA-256 is reported on the certificate as "chain broken at event N".
    • Tail truncation leaves a chain that is still internally valid. It shows up only against the audit_head line printed to stdout after every commit (lib/documents.js:91-104), and only if those lines were shipped off the host. Nothing in the product compares the DB against them.
    • The certificate prints the head as of the Signed event. The Completed event that follows records both file hashes in the chain (lib/documents.js:352). The Uploaded and template events now carry the original SHA-256 (verified in the DB), so the chain binds it from the start.
    • Anyone who holds the key can still rewrite the chain. When the key lives in DATA_DIR (no ENCRYPTION_KEY), a disk-level attacker has it.

0b. Tier 1 controls: seal, signer email code, expiry (reviewed 2026-11-02)

Evidence: test/security/tier1.test.mjs, run after the T1 fix round against a fresh server (explicit ENCRYPTION_KEY, DATA_DIR checks on, no SMTP, no APP_URL): 25 tests, 24 pass, 0 fail, 1 skipped (the full T1-1 reset test needs SMTP; it later passed in the QA Tier 1 run B with the SMTP sink, as reported by the orchestrator), 0 todo. The T1-6 test boots next start from the existing build twice in a throwaway DATA_DIR. The server has no SMTP, so the OTP tests switch require_otp on in the throwaway DB and plant a code hash the way POST /otp {action:'send'} would. The send path (cooldown, 10-send cap, mail) and the T1-2 re-check were reviewed in code only.

Control Implemented Verified by test Limits
Digital seal (lib/pdfsign.js, lib/stamp.js:155-156) A PKCS#7 detached signature (adbe.pkcs7.detached) is added as the last step of building the signed PDF. signed_sha256 is over the final sealed bytes Exactly one signature. /ByteRange starts at 0 and ends at EOF, and the only gap is the /Contents hex. The CMS messageDigest equals SHA-256 of the covered bytes, and the RSA signature verifies with the embedded certificate. A flipped byte breaks the digest. DB signed_sha256 equals SHA-256 of the download. The certificate page prints the same fingerprint Self-signed by default: it shows a PDF was changed after completion, not who sealed it. No trusted CA, no RFC 3161 timestamp, no LTV. Anyone can append an incremental update; readers then report changes after signing. Re-saving an uploaded PDF invalidates any signatures it already had
Seal key (lib/pdfsign.js:26-90) RSA 2048, SHA-256, CN "Countersign seal (host)", 10 years. Generated on first use and stored as DATA_DIR/signing.p12 with writeEncrypted(..., 'signing-p12'). An operator P12 comes from SIGNING_P12_BASE64 and SIGNING_P12_PASSPHRASE The file is CSE1 (not a plaintext PKCS#12). It decrypts only with AAD signing-p12. The key is at least 2048 bits, the certificate is sha256WithRSA, and it is the certificate inside the PDF. No .tmp file is left behind. An unreadable or expired SIGNING_P12_BASE64 makes the process exit 1 at boot with "Startup check failed", and the P12 is not echoed (T1-6, tested). Certificate validity is re-checked on every seal (code review). A PDF with the literal @signpdf placeholder is refused at upload with 400 (T1-4, tested); a variant that slips past the upload check fails completion with 422, the document stays sent (T1-10, tested) The P12 inside the ciphertext has an empty passphrase, so its protection is the data key (with no ENCRYPTION_KEY, the key sits beside it). No rotation. T1-10: the upload check is narrower than @signpdf's parser
Signer email code (lib/documents.js:323-421, pages/api/sign/[token]/otp.js) 6 digits from crypto.randomInt. Stored as keyedHash('otp', recipientId:code), valid 10 minutes, at most 5 wrong guesses per code, 60 s between sends, at most 10 sends per recipient. Compared with timingSafeEqual. On success the code is burned and a 256-bit session is issued as the cs_otp_<recipient> cookie (HttpOnly, SameSite=Lax, Path=/, 12 h, Secure on https), stored only as keyedHash('otp-session', ...) Before verification the page has no fields, message, labels or full email, and is not recorded as Viewed. This now holds for every page state: queued signer and cc get doc: {title}, me: null, no fields, and no Viewed event (T1-3, tested). A reminder that was not delivered (no SMTP) leaves the send cap and attempt count untouched (T1-1, tested). File, download, submit and decline return 403. An owner session does not count. The completed PDF is also gated. The cookie has the right flags, and the stored hash is keyed and contains no plaintext. A code can't be replayed. A new verification ends the old session, and an expired session is refused. Neither a cookie nor a code of recipient A works for recipient B. 20 parallel guesses: exactly 4 evaluated as wrong, the 5th locks, the right code is then refused, and there are 4 otp_failure and 1 otp_locked events with no code in them. Non-string codes never verify. Unknown and revoked links answer like the other token routes Brute force: 5/10^6 per code and 10 codes per link, so at most 5x10^-5 per link, and only for someone who already holds the link. An owner reminder resets the send cap and attempt count and logs ". Email code limit reset" (T1-1; the reset path itself needs SMTP and is verified only by code review until the QA SMTP sink run). Audit change from T1-3: an unverified cc or queued signer who opens the link is no longer recorded as Viewed. A code only proves access to the mailbox at that moment
Per-document expiry (lib/documents.js:270-311) expires_at is set at send. Expiry happens lazily on any token lookup and in the hourly job: status expired, links revoked, an Expired event, the owner is emailed Past the deadline, submit, decline, file and code all return 410 "This document expired on ...". The page returns 410 with state expired and no token. Links are revoked, there is exactly one Expired event, remind returns 409, the owner can delete. expireDays is clamped to 0-365. A completed document stays downloadable after its deadline Remind on an overdue document expires it (one Expired event, links revoked, token not extended) and returns 409 "This document has expired" before any link lookup (T1-5, tested). The re-check under the write lock refuses a submission past expires_at with 410 (T1-2, code review only: the race is not reproducible deterministically)
Self-sign (lib/documents.js:240-244) Exactly one recipient, a signer whose email equals the owner's (lowercased on both sides). No invitation. require_otp is stored as 0 Rejected (400, the document stays a draft with no token minted) for another email, owner plus cc, owner plus a second signer, and a look-alike email. Mixed-case emails work. Another user gets 404 Account emails are unverified (T1-7)
New field types (lib/documents.js:14-27,466-480) Dropdown and radio options: 1-20 unique trimmed strings of 1-60 characters, or the field is dropped. Number regex. Stamp uses the signature PNG checks Bad option lists are dropped, and options on other types are ignored. Out-of-list, case-changed, __proto__ and array or object values are rejected. Nine bad number forms are rejected, including exponent, hex, Arabic digits and 16 digits. Stamps with 5000 px, more than 400,000 characters or an SVG data URL are rejected. HTML in options, labels and names is escaped on the signing page and in the editor Text in the PDF is drawn as hex strings, so field text can't inject PDF syntax (code review)

0c. Tier 2 controls: contacts, saved signatures, verify, redirect, email templates (reviewed 2026-11-02)

Evidence: test/security/tier2.test.mjs, 13 tests, run twice against fresh servers from the existing build (explicit ENCRYPTION_KEY, SIGNUP_MAX_PER_HOUR=1000, DATA_DIR checks on, no TRUST_PROXY). Run A (no SMTP, no APP_URL): 12 pass, 0 fail, 1 skipped (email rendering needs SMTP). Run B (SMTP_HOST=127.0.0.1, a capturing sink started by the test, APP_URL set): 13 pass, 0 fail, 0 skipped. No todo tests: none of the open findings below is a direct exploit that a test can assert as fixed.

T2 fix round (verified by test in the 2026-11-02 runs A and B). The test file now has 14 tests. It was updated for the decided fixes: an http: redirect and a non-string redirectUrl return 400, an original match is exactly {match: 'original'}, a signed match has no sealed key, nine malformed emails (,;<>"()[]\, space, tab) return 400 and are skipped by import, and a new test fills one owner to 10,000 contacts and expects the next create to return 400 "You have reached the limit of 10,000 contacts". It ran with no failures in both full-suite runs (A: 147 pass, 0 fail, 9 skipped; B: 153 pass, 0 fail, 3 skipped, of 156). The results of the earlier runs above apply to the pre-fix code.

Control Implemented Verified by test Limits
Contacts isolation (lib/contacts.js, pages/api/contacts/**) Every query is scoped by owner_id. Edit and delete use WHERE id = ? AND owner_id = ? and return 404 when nothing matches Another owner can't list, edit or delete (404) and the row is unchanged. The same email imported by B lands only in B's book. Every route returns 401 without a session. Cross-origin writes return 403 Per-user isolation only (no organisations). Cap of 10,000 contacts per owner on create and import (T2-7, verified by test in the 2026-11-02 runs)
Contacts search q is cut to 200 characters. \, % and _ are escaped and passed as bound parameters with ESCAPE '\'. limit is clamped to 1-200 and offset to 0 or more %, _, r_s, \ and ' OR 1=1 -- match only literally. limit=100000, negative, abc, 1e309, repeated q and a 5000-character q all return 200 with sane results —
Email uniqueness UNIQUE(owner_id, email) with COLLATE NOCASE Mixed-case duplicates return 409 on create and edit. A mixed-case import updates the existing contact. Bad name or email types return 400 NOCASE folds ASCII only. The shared EMAIL regex now rejects ,;<>"()[]\ and whitespace (T2-4, verified by test in the 2026-11-02 runs)
CSV import (lib/contacts.js:84-121) text/csv only (415 otherwise), 1 MB (413), 2000 data rows (400), an unclosed quote returns 400. The parser is linear, and fields are cut to 120 and 255 characters 1 MB quote bomb, one huge quoted field, a million columns, a million blank lines and a 1 MB name all finish in under 5 s with 200 or 400. A huge name is stored as 120 characters. The header maps columns by name in either order. A cross-origin import is refused Formula-like names (=HYPERLINK(...)) are stored verbatim. Harmless until a CSV export exists (T2-5)
Saved signature (lib/signatures.js, pages/api/signatures.js) The checkSignaturePng from signing is reused. The value is encrypted with AAD usersig:<user id>:<kind>. GET sends private, no-store SVG, a non-PNG, 4001 px, 2001x2001 (over 4 M pixels), more than 400,000 characters, bad base64 and non-string values return 400. A __proto__ kind returns 400. The DB holds no plaintext. A ciphertext copied to another user, or from signature to initials, fails decryption (non-200) and is never returned. Cross-origin DELETE returns 403 Key in DATA_DIR when ENCRYPTION_KEY is unset (section 0)
Saved signature on the signing page (pages/sign/[token].js:39-50) Sent only when the page state is sign, signerVerified is true, and the signed-in account's email equals the recipient's (case-insensitive). The value is always the viewer's own Cross-account case: B sends to A's address in another case, and A's own signature is offered. The sender B, an unrelated user C and an anonymous viewer get nothing, and B's signature never appears in the page. With an email code required and not verified, the state is verify and nothing is offered. After verification it is offered to A, not to C holding the same code cookie. Status pages after signing carry nothing. The page response is no-store/private signerVerified is true when no code is required (lib/documents.js:374), so on such documents the gate is the account email alone. Account emails are unverified (T1-7). This exposes no one else's data (T2-6)
Public verify (pages/api/verify.js) 64-hex SHA-256, 60 lookups per hour per client IP (verify:<ip>, counted before validation), no-store. A signed match returns title, completion time, and signer names with signing times. An original match returns {match: 'original'} only (T2-3). IPv6 clients are keyed on their /64 (T2-2). Templates are excluded, and deleted documents are gone from the table Bad input returns 400. A signed match has exactly {match, title, completedAt, signers[{name, signedAt}]} (no sealed, T2-9), with no emails, ids, token or message. The original of a draft, a sent and a completed document returns exactly {match: 'original'} (T2-3). Both verified by test in the 2026-11-02 runs. A template original and a deleted draft return {match: null}. The 61st lookup returns 429 with Retry-After. A spoofed X-Forwarded-For doesn't reset the limit without TRUST_PROXY The original match still tells anyone who holds a copy of the original that it was uploaded here (no status). Behind a reverse proxy without TRUST_PROXY=true, all visitors share one bucket (documented, no boot warning). The /64 keying can't be exercised from a loopback test (code review)
Redirect after signing (lib/documents.js:192-202, pages/sign/[token].js:202-215) Only https: as parsed by WHATWG URL (T2-1). A non-string value returns 400. No user:pass@. At most 500 characters raw and normalised. Stored as u.href. Returned only by a successful submit, which requires verification. The browser re-checks the URL before showing "Continue to ". No automatic redirect: the signer must click (T2-1). Referrer-Policy: same-origin 16 hostile forms return 400, including javascript: (mixed case, tab, leading space), data:, vbscript:, file:, protocol-relative, relative, scheme-less, credentials (including %75ser@), ftp:, blob: and 501+ characters. Case and whitespace are normalised. A Cyrillic look-alike host is stored as punycode. http: (three forms) returns 400. Non-strings (array, object, number, boolean) return 400 and leave the stored value unchanged (verified by test in the 2026-11-02 runs). Before the code: not in the page, and submit returns 403 without it. After verification: not in the page props, and present only in the submit response. With no redirect the response is still {status} Any account holder can still offer signers a link to an https: site of their choice from this server's domain, behind a click. Click-only behaviour is UI and is verified by code review, not by this HTTP test
Email templates (lib/mail.js:52-81, pages/api/email-templates.js) Per user (renderEmail(owner.id, ...)). CR/LF are stripped from the subject at save and again after substitution, with 200 and 4000 character limits. One-pass substitution of known variables only. All text is escaped in the HTML. The action button is added by the caller Stored subject has no CR/LF. Over-long, blank and non-string values and unknown kinds (constructor too) return 400. A cross-origin PUT returns 403, and without a session 401. B never sees or receives A's template. SMTP sink: a title containing \r\nBcc: victim produced no Bcc header and no extra RCPT. {{sender_email}} inside a title or message is not re-expanded. {{constructor}} and {{__proto__}} stay literal. HTML in the template, message, recipient name and sender name is escaped. The sign link is present in the HTML and text parts. The completion mail uses the owner's completed template and keeps the download link The template author can write any text, including look-alike URLs (escaped text, never an HTML link, though mail clients may auto-link a typed URL). That is the same trust as the existing message field (T2-8)

E-signature frameworks (feature mapping, not a compliance claim; legal review is outside this codebase).

  • ESIGN/UETA: intent: an explicit sign action and a signature image (a mark of intent, not proof of identity). Attribution: the link, plus the email code when it is turned on. The certificate records "Email verified by one-time code at ...". Association and integrity: the seal and signed_sha256. Retention: completed PDFs are kept and can't be deleted, but there is no retention policy or export. Consent to electronic records: no explicit consent step (Gap).
  • eIDAS: at best a simple electronic signature. A self-signed or operator seal with an email code doesn't meet "uniquely linked to and capable of identifying the signatory" under the signatory's sole control (AdES). No qualified certificate or QSCD (QES), and no qualified timestamp.

0d. Tier 3 wave 1 controls: API keys, webhooks, folders, bulk send, public template link (reviewed 2026-11-03)

Evidence: test/security/tier3.test.mjs. Before the fixes (Raymond, 2026-11-03, two fresh servers from the pre-fix build: A with WEBHOOK_ALLOW_PRIVATE=true WEBHOOK_ALLOW_HTTP=true, B with default webhook settings, explicit ENCRYPTION_KEY, SIGNUP_MAX_PER_HOUR=1000, no TRUST_PROXY, no SMTP): 23 tests, 18 pass, 0 fail, 2 todo (T3-02 measured 80 simultaneous connections; T3-04), 3 skipped. The fix tests, forced on against that old build, failed for the expected reasons. After the 2026-11-03 fix round the file has 26 tests: the two todo tests became real assertions, tests for T3-03, T3-07, the 415 check and the bulk limit were added, and the T3_FIXES gate was removed. The orchestrator ran it in two full-suite runs against the fixed build: run A (email off) 189 pass, 1 fail, 17 skipped; run B (email on) 202 pass, 0 fail, 5 skipped. The one run A failure is outside Tier 3 (F-1, flaky). The per-test results in those runs are as reported by the orchestrator; Raymond did not re-run them. Raymond ran node --check on the updated file, scripts/tier3-selfcheck.mjs (passed), and a direct blockedAddress probe against the fixed source: the four T3-05 prefixes now return true, and 8.8.8.8 and 2606:4700:4700::1111 stay false.

Control Implemented Verified by test Limits
API key authentication (lib/http.js api(), lib/apikeys.js) cs_ + 32 random bytes, shown once, stored only as keyedHash('apikey', key) plus a 10-character display prefix. Any Authorization: Bearer header takes the key path: a session cookie is never consulted, and the origin check is skipped only after the key resolves. A bearer header on a route without apiKey: true returns 401 "API keys cannot be used here". 600 requests/hour per key The DB holds only the keyed hash. Revocation gives 401 on the next request. A valid key is refused on 17 session-only routes (account, keys, webhooks, auth incl. logout/logout-all, contacts, signatures, email templates, sign, public templates, verify), and the refused logout or key delete changes nothing. Session + foreign Origin gives 403. Invalid key + foreign Origin + valid cookie gives 401 with nothing changed. Five malformed bearer headers give 401. Valid key + foreign Origin is allowed (no cookie involved). A's key with B's cookie acts as A only. A's key gets 404 on B's documents. The 600th request is allowed, the 601st gets 429 with Retry-After, and another key of the same user is unaffected Expiry, read and write scopes, revocation on password change and optional revocation on logout-all were added 2026-11-04 (section 0f). Key callers no longer see signer tokens (T3-04, fixed)
Document list/detail via key (lib/folders.js listDocuments, pages/api/documents/[id]/index.js) Owner-scoped query with bound parameters. limit clamped to 1-200, offset to 0 or more. Detail strips public_token_hash/public_token_enc, and recipients carry no token hash, ciphertext or OTP data. The plaintext signing token goes to session callers only, never to an API key (T3-04) B's documents never appear. limit=100000, negative offset and an SQL-like status are handled. Cache-Control: private, no-store. publicUrl is shown to the owner only. T3-04: GET /api/documents/[id] gives signer tokens to the session owner only, never to an API key: a key caller gets no token key and no token value anywhere in the response, while the session owner still gets the link —
Webhook SSRF and delivery (lib/webhooks.js checkUrl, safeLookup, post, deliverDue) https: only by default, no credentials, at most 500 characters. Literal IPs are checked at create and again before connect. Hostnames are resolved through a lookup that drops blocked addresses, so the socket only connects to an address that passed (no rebinding gap). Blocked: 0/8, 10/8, 100.64/10, 127/8, 169.254/16, 172.16/12, 192.0.0/24, 192.168/16, 198.18/15, 224/4, 240/4, ::/96, NAT64, fc00::/7, fe80::/10, fec0::/10, ff00::/8, IPv4-mapped forms, and (T3-05) ::ffff:0:0/96, 2002::/16, 2001::/32, 100::/64. agent: false, no redirects, 10 s timeout, response body never read. Delivery runs are coalesced to one per process, with the state on globalThis because Next bundles the module separately per route and in instrumentation. The run loops while a batch comes back full (T3-02) Server B: http: refused. 22 literal forms refused with "This address is not allowed" (127.1, 2130706433, 0x7f.0.0.1, 0, [::1], [::], [::ffff:127.0.0.1], [::ffff:a9fe:a9fe], 169.254.169.254, RFC 1918, CGNAT, fd00::1, fe80::1, multicast, NAT64, and the four T3-05 prefixes). https://localhost:1 passes create, but the delivery fails with "This address is not allowed" (the connect-time check). Server A: a 302 is recorded as a failure (HTTP 302) and the redirect target is never contacted. T3-02: webhook deliveries are coalesced, so an 80-row bulk opens at most 50 connections to one receiver: at most 50 at once, all 80 delivered exactly once Coalescing is per process: several processes on one DB each run their own batches (the lease still stops two attempts of one delivery)
Webhook signature Countersign-Signature: t=<unix>,v1=hex(HMAC-SHA256(secret, "<t>.<raw body>")). The secret is whsec_ + 32 bytes, shown once, stored encrypted with AAD webhook:<id>. A new t on each attempt Every delivery of a full signing flow verifies with an independent HMAC and constant-time compare. A one-byte tamper fails. Countersign-Delivery equals the payload id. The DB and the list response never hold the secret Replay protection is up to the receiver (check t and dedupe on the delivery id). Secret rotation added 2026-11-04, with no overlap period for the old secret (section 0f)
Webhook payload whitelist pick() keeps only document{id,title,status} and recipient{id,name,email,role,status} Four events (document.sent, recipient.viewed, recipient.signed, document.completed), document.sent first. Exact key sets. No signing token, no field value (a unique text value), no signature PNG, no secret, and no otp/token/sha256 substrings in any body Recipient emails reach the owner's receiver by design
Webhook management Session only. Every query is scoped by owner_id. Events whitelisted (ping is not subscribable). 10 per user B gets 404 on PUT, DELETE, test and deliveries for A's webhook, and A's URL is unchanged. Credentials, an empty or unknown event list, javascript: and 501+ character URLs give 400 —
Folders and move (lib/folders.js) Every query has owner_id. Move checks the folder belongs to the document owner. Delete unfiles and never deletes documents B can't rename, delete, list, filter by or file into A's folder, and can't unfile A's document (404). Non-string folderId gives 404. Deleting a folder leaves the document with folder_id null —
Bulk send (lib/documents.js bulkRows/bulkSend, route [action].js) Template only. 1-500 rows, every row validated before anything is created. Emails unique within a row and matched by the shared EMAIL regex. Body 1 MB for JSON and CSV. Any other content type gets 415 "Send JSON or CSV" (T3-08). 10 bulk sends per hour per user, counted before validation and shared by the user's API keys, then 429 "Too many bulk sends. Try again later." (T3-01). The template is read and verified once per send, and a copy whose DB insert fails deletes the PDF it wrote (T3-01, code review). Idempotency-Key (8-64 characters of [A-Za-z0-9_-]) becomes the bulk_id, and a reuse by the same owner gets 409 501 rows, a bad email in row 2, a case-folded duplicate in row 3, a wrong role count, a 201-character name, a CR/LF header-injection email and a non-array rows all give 400 with nothing created. A 1 MB + 1 CSV gives 413. Non-template (JSON and CSV) gives 409. B on A's template gives 404 (JSON and CSV). A good send stamps one bulk_id on every copy. T3-08 and T3-01: document actions take JSON or CSV only (415); bulk sends are limited to 10 per hour per user: text/plain, form, multipart and no type give 415, and the 11th bulk send gets 429 with Retry-After (also through the user's key, nothing created, another user unaffected). fix: bulk send Idempotency-Key is validated and a reuse by the same owner is refused: five bad keys give 400, a reuse gives 409 (JSON and CSV) with nothing created, and another owner with the same key gets 201 Each copy still stores its own encrypted PDF, and there is no storage quota (T3-01, partly open)
Public template link (setPublicLink, createFromPublicLink, pages/api/public-templates/[token].js, pages/t/[token].js) 32-byte token stored as SHA-256 plus ciphertext (AAD public:<id>). Enabling always rotates, and disabling clears both. Template only, exactly one signer, and it must be sendable: a save that makes it unsendable turns the link off (T3-07). Per-IP limit of 10/hour counted before the honeypot and the token lookup. Per-template cap of 50/day keyed on sha256(token), so rotation starts a fresh budget (T3-10). Copies expire after 1 day when the template sets none. A copy without email codes sends no signer completion email; the owner's still goes (T3-03). Same-origin check on. The page gets title, owner name, OTP flag and page count only The hash matches and the ciphertext doesn't contain the token. A rotated or disabled link gives 404 and the same page props as a never-issued token. Saving a second signer turns the link off. A cross-origin POST gives 403. The honeypot gives {ok:true} and creates nothing. Success returns exactly {signUrl}. public link: honeypot creates nothing; success gives only a signUrl; cross-origin POST blocked; per-template cap: the cap gives 429 with Retry-After, and after rotation the new token gets 200 with its counter at 1. The 50th copy of the day is allowed and the 51st gets 429, and a copy gets expire_days 1 with expires_at about 24 h out. A template without a signature field can't get a link (400), and T3-07: removing the signature field from a template turns its public link off. T3-03: a completed public-link copy without email codes mails the owner, never the visitor-typed address (SMTP sink): the owner gets the completion email and nothing is sent to the typed address. Unknown-token probes use up the IP budget, then a valid token, a spoofed X-Forwarded-For and the honeypot all get 429. A hostile title and owner name are escaped in the HTML, and the owner email and template id are absent With email codes on, the code email still goes to the typed address (that is the check). Behind a proxy without TRUST_PROXY all visitors share one bucket (T3-09, operator)
New UI (components/DeveloperSettings.js, components/TemplateTools.js, pages/t/[token].js, dashboard folders) React text rendering only. No dangerouslySetInnerHTML, and no href built from user data other than relative app paths /t/<token> escaping tested over HTTP. The other components were checked by code review (grep for sinks) Settings and dashboard XSS not exercised in a browser

E-signature mapping, Tier 3 (feature mapping, not a compliance claim). Public links: the signer's identity is whatever they type, and the audit event says so ("Signer name and email were entered by the signer"). Without the email code, attribution rests on the typed address alone. With it, on control of that mailbox. Bulk send and API keys change who starts a document, not how it is signed. Webhooks are notifications, not records: the retained record is still the signed PDF and the audit trail.

0e. Tier 3 wave 2 controls: teams (reviewed 2026-11-03)

Evidence: test/security/teams.test.mjs. Raymond ran it against the wave 2 build (uncommitted on top of 98d8ed4) on two fresh servers with an explicit ENCRYPTION_KEY, SIGNUP_MAX_PER_HOUR=1000, no TRUST_PROXY and no SMTP: A with ALLOW_SIGNUP=true on port 3491, B with ALLOW_SIGNUP=false on port 3493. Result: 15 tests, 12 pass, 0 fail, 3 todo (TW2-1, TW2-2 and TW2-3; each failed because it asserted the fixed behaviour). After the 2026-11-03 TW2 fix round the three todo tests became real assertions (TW2-1 rewritten for the "first account only" decision). In the orchestrator's teams-closed run, test/teams.test.mjs and test/security/teams.test.mjs ran against one open server and two fresh ALLOW_SIGNUP=false servers: 31 tests, 31 pass, 0 fail, 0 skipped. The per-test results are as reported by the orchestrator; Raymond ran node --check on the updated file and did not re-run it.

Control Implemented Verified by test Limits
Access modes (lib/documents.js ownedDocument/canAccess, document routes) own means the owner only (move, public, share). write means the owner, or the owner/admin of the document's team (PUT, DELETE, send, void, remind, copy of a non-template). read means the owner or any member of that team (GET, file, document page, copy/bulk from a template). A document with team_id NULL is owner-only. Every refusal is 404 "Document not found" Another member, an outsider and the owner of another team get 404 on PUT, DELETE, send, void, remind and non-template copy, and nothing changes. The team owner, admins and other members get 404 on move, public and share of a member's document. A personal document is 404 for every teammate, both through the API and on its page. Admin PUT and void work, and the void event names the admin. An admin's copy is owned by the admin. On delete, the tombstone deleted_by is the acting team owner. Members can copy and bulk-send team templates, the copies are owned by them in their team and carry no folder, and they get 404 when they try to edit the template Admin powers over members' documents are by design and include their signing links
Signing links (pages/api/documents/[id]/index.js, pages/app/documents/[id]/index.js) Tokens go only to session callers with write access. API keys never get them A read-only member gets no token key, and the token string is absent from the API body and from the document page HTML (canWrite false). The team admin gets the link from the API and on the page. The keys of the owner, the admin and a reader never return it. Removing the admin gives 404 on their next request publicUrl goes to the document owner only, including the owner's own key (TW2-3, fixed)
Lists (lib/folders.js listDocuments, dashboard) owner_id = me OR team_id = myTeam, with bound parameters. The folder filter is ANDed with owner_id = me. Other users' folderId is null Outsiders and other teams never see team documents. scope=team without a team returns nothing. A teammate filtering by my folder id gets nothing. The row shows ownerName and teamShared The dashboard SSR nulls teammates' folder_id too (TW2-5, fixed, code review)
Roles (lib/teams.js setRole, removeMember, leaveTeam, deleteTeam) Only the owner changes roles. Making someone owner demotes the current owner to admin, so there is exactly one owner. Admins remove members only. The owner can't change their own role or leave while others remain. All of it runs in BEGIN IMMEDIATE An admin making themselves owner, promoting or demoting others, removing the owner, inviting an admin, or renaming or deleting the team gets 403. A member gets 403 on invites. A role of owner or superuser gets 400. The owner of another team gets 404. The owner's self-demotion gives 400 and leaving gives 409. After a transfer the DB holds exactly one owner, and the old owner loses owner rights Removal, leaving and demotion to member revoke that user's pending invites (TW2-2, fixed)
Leave, remove, delete The leaver's or removed user's team documents get team_id NULL. Deleting the team clears team_id on all its documents and cascades members and invites (PRAGMA foreign_keys = ON) Leave and removal make the documents personal, so teammates get 404, and the leaver keeps their own documents but loses team access. After a delete the documents are personal, no member or invite rows remain, and a pending invite's join page gives 404 —
Invites (createInvite, liveInvite, joinWithInvite) 32 random bytes in base64url, stored only as keyedHash('team-invite', token). Expires in 7 days, single use, revocable. Re-inviting an address revokes the old invite. At most 50 pending invites per team and 30 invite creations per hour per user. Joining needs a session whose email equals the invite email (case-insensitive) The hash matches, and no column or list response holds the token. The TTL is 7 days. A wrong account gets 403 and stays teamless. undefined, a number, { $ne: 1 }, 'x' and a one-character-off token give 404. A second use, a revoked invite and an expired invite (expiry set in the DB) give 404 in the API, a 404 join page and invite: null on /signup. A user already in a team gets 409. A member can't revoke The email match isn't proof of mailbox control, because account emails are unverified (T1-7). The token is the real credential
Invite sign-up on a closed server (pages/api/auth/signup.js, pages/signup.js) Sign-up opens only when the inviteToken names a live invite whose email equals the sign-up email. The user insert and the invite acceptance share one transaction, and the invite is re-checked inside it. The prefill comes from the server-side lookup Server B: no invite, a different email, and '', 'x', object and array tokens give 403. /signup?invite=<live> gets closed: false with the invite email, while an unknown token or no token gets closed: true and invite: null. Five concurrent sign-ups with the same invite and email give exactly one 200, no 5xx, and the account joins as member. Once accepted or revoked, the invite no longer opens sign-up (403) While sign-ups are closed, only the first account may create a team, and invite sign-up works only for a team it owns; anything else gets 403 and no user (TW2-1, fixed: the user chose "first account only"). Admins of that team can still invite. scrypt now runs before the write lock (TW2-4, fixed, code review)
API keys and CSRF on team routes /api/teams/** uses api() without apiKey, so a bearer header gets 401. Writes check Origin. The share action uses own mode A valid key, even sent with a valid cookie, gets 401 "API keys cannot be used here" on all 11 team routes, and the refused DELETE changes nothing. The team owner's key gets 404 on share of a member's document, and the document owner's key works. A foreign Origin gets 403 on all 8 team writes and on share, and the team, roles and team_id are unchanged Document routes accept keys: a member's or admin's key carries their team read or write access (TW2-7)
XSS (team, member and inviter names) React text rendering, and no dangerouslySetInnerHTML (grep). Invite email: escaped HTML and a CR/LF-stripped subject in lib/mail.js An <img onerror>/</script><script> payload in the team name, the owner name and the member name is absent from the raw HTML of /join/<token>, /signup?invite=, /app/team (owner and member), /app and the document page, and inviterName round-trips as text Not exercised in a browser. Names are free text in emails (TW2-6)

E-signature mapping, teams (feature mapping, not a compliance claim). Teams change who can see and manage an envelope, not how it is signed. A team admin can open, send, void and delete a member's documents and receives their signing links. The audit trail names the acting user (verified for void and delete), so attribution of sender actions holds. Signer intent and association are unchanged. Record retention is unchanged too: leaving or removal makes documents personal again and never deletes them. Sharing and unsharing are recorded in the document audit trail as "Shared with team" and "Made private", with the acting owner (TW2-8, closed).

0f. Single sign-on and the 2026-11-04 hardening commits (reviewed 2026-11-04)

Evidence: test/security/oidc.test.mjs, run against a fresh disposable server (next start from the existing build, port 3510, ALLOW_SIGNUP=true, OIDC_ALLOWED_DOMAINS=allowed.test, explicit ENCRYPTION_KEY, no SMTP, mock provider in the test process): 10 tests, 5 pass, 0 fail, 5 todo. Each todo test asserts the fixed behaviour of an open finding and currently fails for the reason given in that finding. Without its environment variables the file skips all 10 tests. Hand runs on two more fresh servers (ports 3512 and 3514) produced the closed-server results for SSO-1, SSO-3 and SSO-4. test/hardening.test.mjs against a fresh server (port 3516, no SMTP): 9 tests, 8 pass, 0 fail, 1 skipped (the invite email cap needs the mail sink, so that cap was reviewed in code only). No real provider, and no browser, was used: SameSite and redirect behaviour is from the cookie attributes and code review.

Second review, 2026-11-04, of 6da7ec3 and the uncommitted fixes on top of it (next start from the existing build, fresh temp DATA_DIR, ports 3510-3519, no SMTP, mock provider, no next build). test/security/oidc.test.mjs: 10 tests, 10 pass, 0 fail, 0 todo. test/security/oidc-round2.test.mjs: 12 tests, 6 pass, 0 fail, 6 todo; each todo asserts the fixed behaviour of an open finding (SSO-9, SSO-17 twice, SSO-18, SSO-19, SSO-20) and fails today for the reason in that finding. Without its environment variables that file skips all 12. Hand runs on fresh servers (ports 3517 and 3518, mock on 3519) produced the results for SSO-3, SSO-21, SSO-22, SSO-23 and SSO-24. The hourly job was run in a child process on the server's DATA_DIR, not by waiting for the timer. SSO-4, SSO-10, SSO-11, SSO-12 and SSO-14 were checked by reading the code, not by a run in this review.

Third review, 2026-11-05, of the same uncommitted fixes (next start from the build of 10:33, fresh temp DATA_DIR, ports 3510-3516, mock provider on 3518 and 3519, no SMTP, no next build). That build predates the last edits to connect.js and settings.js (10:39), so the 502 branch in connect.js and the Settings button were read, not run. test/security/oidc.test.mjs: 10 tests, 10 pass, 0 fail. test/security/oidc-round2.test.mjs: 22 tests, 20 pass, 0 fail, 2 todo (SSO-9, SSO-23), each failing for the reason in that finding. Hand runs produced the SSO-9 measurements and SSO-28. X-Forwarded-For on a TRUST_PROXY=true server stood in for separate addresses. No real provider and no browser were used.

Control Implemented Verified by test Limits
Code flow with PKCE (lib/oidc.js) response_type=code, S256 challenge, 256-bit state, nonce and verifier, redirect address from APP_URL only, provider endpoints https unless OIDC_ALLOW_HTTP on a non-https APP_URL, no redirects followed, 10 s timeouts The start redirect, secret, redirect address and verifier are covered in test/oidc.test.mjs (read, not re-run in this review) One provider per server. A changed issuer disconnects everyone and turns passwords back on with no warning (SSO-21, SSO-28)
ID token checks iss, aud, azp whenever present or with several audiences, exp, nonce and sub are checked. The signature is not, because the token comes directly from the token endpoint over TLS (OIDC Core 3.1.3.7) Same, in test/oidc.test.mjs (read, not re-run) Holds only while tokens arrive by that direct route
Flow cookie (pages/api/auth/oidc/start.js, callback.js) AES-256-GCM with AAD oidc-flow, Path=/api/auth/oidc; HttpOnly; SameSite=Lax; Max-Age=600, Secure on https, cleared after every callback. state is compared by constant-time hash compare before any other callback input is used flow cookie: scoped to the SSO routes.... callback: no state, a repeated state and another flow's state give no session. a saved flow cookie replayed with its used code gets no session, because the provider refuses the code No server-side one-time use: a replay is stopped by the provider's one-use code, so the cookie alone is not single use. Not __Host- (SSO-15)
Redirect after sign-in (safePath) Starts with one /, no backslash, printable ASCII only 18 hostile forms all resolve to the server's own origin with a 302, including /..//evil.test, /%5Cevil.test and \t//evil.test. A non-Latin-1 path gives a 302 None known
Secrets in logs Only fixed messages and validated emails reach detail logs: no client secret, code, state, nonce, flow cookie or id token..., over success, unverified email, token endpoint 500 and wrong issuer The email of a refused person is written to detail. It comes from the provider, not from a typed field
Account match (issuer, subject). An existing account links only from its own live session. New accounts by verified email SSO-1: an email match alone never opens an existing account.... SSO-1 holds: a session that has ended.... SSO-1 holds: an identity connected from a session opens that same account afterwards Linking needs a flow begun by same-origin POST /api/auth/oidc/connect for the same user, and a live session never switches accounts. Tested: SSO-22: Connect refuses another origin..., SSO-22: a Connect flow belongs to the account that began it, SSO-17 through the real Connect flow.... No browser was used
Async scrypt, reserved attempt (lib/auth.js, pages/api/account.js) crypto.scrypt on the thread pool. The attempt is reserved synchronously before the await password change: parallel wrong guesses cannot pass the limit of 5 Four thread-pool slots are shared with other libuv work, so a login flood queues rather than blocks. Not measured
Password reset (pages/api/auth/forgot.js, pages/api/auth/reset.js, lib/auth.js, scripts/reset-link.mjs) 192-bit token, stored as SHA-256, one hour, single use, spent after the new password is hashed. The link comes from APP_URL only and carries the token in the fragment. Same answer for known and unknown accounts, with the work done at a random moment after the response. 20 requests an hour per address (per /64 on IPv6), 3 emails an hour per typed address, which is stored only as a keyed hash. In one transaction, reset ends all sessions, revokes keys and pending invites, pauses webhooks, clears lockouts, and the owner gets an email. A password change kills links already sent. Refused for accounts connected to the current issuer, and under OIDC_REQUIRED for identities at any issuer test/reset.test.mjs, test/security/reset.test.mjs (section 0g) Mailbox access is enough to reset a password that is not connected to SSO. See section 0g
API key expiry and scopes (lib/apikeys.js, api() in lib/http.js) Expiry checked in the lookup query. A read key is refused on anything but GET and HEAD, in one place. Existing keys default to write API keys: a key past expires_at is refused. API key scopes: read keys only GET... None known for publicUrl: read keys get null (code review)
Revocation (pages/api/account.js, pages/api/auth/logout-all.js) A password change revokes other sessions and all keys and pauses webhooks. Logout-all does both on request password change revokes every API key.... sign out everywhere: keys survive by default.... webhooks pause on a password change... (read, not re-run). SSO-13 holds: API keys cannot pause or re-enable webhooks, nor can another account Connecting under OIDC_REQUIRED ends other sessions but not the one the password opened (SSO-23), and revokes no keys or webhooks. Events during a pause are dropped (SSO-27)
Webhook secret rotation Session only, owner only, old secret stops at once, logged webhook secret rotation: owner only, session only... Queued deliveries are signed with the new secret, and there is no overlap period
Storage quota (checkQuota) Checked on upload, copy, public-link copy and up front for a bulk send storage quota: uploads and copies past STORAGE_QUOTA_MB get 413; deleting frees space Public-link copies are outside the quota, so an owner can pass files through it and visitors can grow the disk without limit (SSO-9, open). Concurrent uploads may overshoot by a file and signed PDFs are not counted. Empty or unreadable means the default. Negative values and whitespace turn the quota off (code review)
Invite email cap, share audit event, canSignUp Server-wide cap, with the link still returned. Share events are in the same transaction as the change. canSignUp is a UI hint and is enforced again in joinWithInvite Cap skipped (needs the mail sink). Share and canSignUp by code review and earlier team tests Any account on an open server can use up the cap. Invites then fall back to the copied link

E-signature mapping, SSO (feature mapping, not a compliance claim). SSO changes how senders and team members sign in. Signers still use a link, plus the email code when it is on. The certificate and audit trail do not record that a sender used SSO.

0g. Password reset (reviewed 2026-11-05)

Evidence: uncommitted working tree on 4f4c461, next start from the existing build, fresh temp DATA_DIR, ports 3520-3524, SMTP sink on 2620-2622, mock provider on 2628, no next build. X-Forwarded-For on a TRUST_PROXY=true server stood in for separate addresses. test/security/reset.test.mjs: 32 tests, 23 pass, 0 fail, 9 todo. Each todo asserts the fixed behaviour of an open finding and fails today for the reason in that finding. Without its environment variables 30 skip, the copy check passes and RST-9 stays todo. test/reset.test.mjs: 9 pass, 1 skipped with mail, 7 pass, 3 skipped without. Hand runs produced the RST-1 accuracy, the RST-2 stream and RST-10. A throw after the answer was forced with SQLite triggers, which also stood in for a failing write in RST-4. A changed issuer was simulated by editing oidc_identities.issuer, since hasIdentity reads it on each call. All on loopback, on Node 26.13. Not run: a browser, a real network, a real provider, a Docker image (docker is not installed here) and Node 24, which is what the image uses.

Claim Result Measured by
Same answer for known and unknown Holds for content. Not for timing (RST-1) Identical status and body. The request right after the answer: 3.81 ms known, 1.72 ms unknown, Mann-Whitney z 8.66, 60 of each
Work after the answer cannot crash the process or delay later requests Holds, apart from RST-1 A trigger threw from the token insert, the log write and the limit row: each answered 200, logged Password reset email failed, and the server stayed up. With a sink that never greets, later requests took a median of 0.81 ms and each attempt held one socket
Link from APP_URL only Holds Forged Host and X-Forwarded-Host with TRUST_PROXY=true: the mail has APP_URL
Fragment, not in logs or Referer Holds on the server side Token absent from database files, WAL and server log. No browser run (RST-12)
SHA-256, one hour, single use Holds 192 bits, stored hash equals SHA-256, 3,600,000 ms. 12 parallel uses: one 200, and that password is the one that works
3 mails an hour per address, 20 requests an hour per address or /64 Holds for each account. New accounts can multiply mail (RST-11) Eight spellings, including upper case, NBSP, BOM and the Kelvin sign, share one count: 3 mails. 21st request from one /64 gets 429
NOCASE look-alikes, mail only to the stored address Holds Sixteen look-alike and injection forms sent no mail to the victim or to evil.example: long s, dotted I, Cyrillic, fullwidth, zero width, NUL, trailing dot, plus, quotes, angle brackets, comma, semicolon, CRLF, group syntax
What was typed stays out of the database and logs Holds A marker typed into the email box is absent from database files, WAL and server log
Reset ends sessions, revokes keys, pauses webhooks Holds for what exists at that moment. Not for a sign-in in flight (RST-2) or a reset that fails midway (RST-4) Second session 401, key 401, webhook inactive, cookie cleared, other links dead
Clearing lockouts is not abusable Holds. It clears less than the name suggests (RST-6) A guesser at another address stays locked at 429 after the reset
Identities, invites, other tokens Identities are safe. Invites survive (RST-3) A live session cannot connect a subject with another email. An invited accomplice joined after the reset
OIDC_REQUIRED: connected accounts have no reset Holds on the current issuer. Not after an issuer change (RST-7) Mail has no link, a link made by hand gets 403 and ends nothing, a link made before connecting gets 403
scripts/reset-link.mjs Works. A wrong DATA_DIR leaves a file (RST-9) Ran with no node_modules, hash and lifetime match, event row written, case-insensitive match only, % and quotes refused. Dockerfile read, not built: .dockerignore does not exclude scripts, the standalone output has no scripts so the COPY is needed, /data and DATA_DIR line up, the compose service is countersign
Same origin and odd bodies Holds Three bad origins get 403 even with a valid token, as JSON and as a form, and the link is not spent. Eleven odd bodies never give a 500
Copy Holds No compliance or legal claim and no tool name in the added files or the diff (text search)

Compliance: these are checks of how the reset behaves. They say nothing about whether any standard or law is met.

1. ISO/IEC 27001:2022 Annex A (software-relevant controls)

Control Requirement (summary) How Countersign meets it today Status To build
5.15 Access control Access rules enforced on every resource Every API route is wrapped in api(), and a session is required unless {auth:false} (lib/http.js:15-29). Documents are checked by owner (lib/documents.js:57-61). Signers are limited to their own token (pages/api/sign/[token]/file.js:6-9). "Needs your signature" lists only the user's own documents (pages/app/index.js:25-30), which closes H1 (tested) Met (per user) Per-user only, no roles or organisations. Email addresses are still unverified, so any future cross-owner feature must not trust them
5.17 Authentication information Secure handling of secrets scrypt with random salt and a timing-safe compare (lib/auth.js:11-22). Session cookies are stored only as SHA-256 (lib/auth.js:126-132). Signing tokens are stored as SHA-256 plus AES-GCM ciphertext, with the plaintext column nulled, and every migrated token gets an expiry (lib/db.js:206-212). Password policy: 12 to 256 characters, not the email (lib/auth.js:32-37) Met No common-password blocklist
5.18 Access rights Provision, review, revoke First user plus ALLOW_SIGNUP (lib/auth.js:183-186). Sign out everywhere (pages/api/auth/logout-all.js) Partial Admin user list: disable or delete a user, revoke their sessions. Email verification
5.28 / 5.33 Evidence, records Protect records from loss and tampering SHA-256 of the original and signed PDF, re-checked on every read (lib/documents.js:26-35). HMAC-keyed per-document chain with the head on the certificate. The Completed event binds both file hashes. Every new head is logged to stdout. The signed PDF is built before completed is committed (lib/documents.js:313-354). Strict PNG validation (:271-287). An audit tombstone is written on delete (:453-468) Partial Truncation is detectable only against off-host logs. A partly failed first migration leaves plaintext PDFs unavailable until fixed by hand. Tombstones don't record the document hashes
5.34 PII protection Minimise and protect PII; support access and erasure Signer PII is limited to name, email, IP and UA. Field values are encrypted at rest Partial PII in the DB is plaintext. No export or erasure of a data subject's data. No retention setting
8.2 Privileged access Restricted admin rights No in-app admin exists. The container runs as non-root (Dockerfile:21) Partial Admin role (see 5.18), with admin actions logged
8.3 Information access restriction Need-to-know at object level Ownership check plus 404 on mismatch. The signer file is gated by status (pages/api/sign/[token]/file.js:7-9) Met (per user) Org scoping when organisations exist
8.5 Secure authentication Strong auth, brute-force protection, session control Login always runs scrypt and returns one generic error (lib/auth.js:26-29). Locks: 5 failures per account+IP, or 50 per IP (accounts with a recent success from that IP are exempt), each for 15 minutes with a 429 and Retry-After. Account-wide failures add a delay instead of a lock (lib/auth.js:39-99). Tested: failures from an attacker IP don't stop the victim signing in from their own IP. Signup is throttled per IP (:110-114), and so is the current-password check (:117-119). Forwarded IPs are trusted only with TRUST_PROXY=true. 60-minute idle and 7-day absolute session timeouts (:153-170). Password change revokes other sessions. Signing links expire and are revoked on void or decline Partial No MFA. L9 closed (scrypt runs on the thread pool). N2 closed: the attempt is reserved before the delay, and a parallel burst is now held to the limit (tested)
8.9 Configuration Secure defaults, hardened config Signups closed by default. poweredByHeader:false and security headers. Forwarded headers are ignored unless TRUST_PROXY is set. Mail refuses to send without APP_URL. Key check at boot (instrumentation.js): verified, a wrong ENCRYPTION_KEY makes the process exit 1 with a clear message Partial No HSTS header. SMTP STARTTLS is opportunistic. TRUST_PROXY trusts exactly one proxy hop
8.10 Information deletion Delete when no longer needed Both PDFs and any .tmp are unlinked; an audit tombstone is kept (lib/documents.js:453-468) Partial No retention policy. Completed documents can never be deleted. Single global key, so crypto-erasure of one document isn't possible
8.12 Data leakage prevention Prevent unintended disclosure Cache-Control: private, no-store on PDFs. Generic 500 bodies. Referrer-Policy: same-origin. Dead-link pages return 404/410 and drop the token from the page props Met —
8.15 Logging Record and protect events Hash-chained (HMAC) document events with an audit_head line on stdout after each commit. Security events go to a table and to JSON on stdout (lib/security-log.js:4-9), including file_integrity_failure and audit_chain_broken. An unknown login identifier is logged only as a hash prefix. Verified: no passwords, cookies, tokens or the key in stdout or in the DB logs Partial security_events is neither chained nor append-only. An unknown login identifier is logged only as a keyed HMAC id (lib/crypto.js:122)
8.16 Monitoring Detect anomalous activity JSON security events and audit heads on stdout, ready for a SIEM Partial Alerting and head comparison are the operator's job. No event for a revoked-token hit (only expired ones)
8.17 Clock sync Reliable timestamps UTC ISO timestamps. The chain keeps event order if the clock steps back Operator NTP on the host. Optional RFC 3161 timestamp on signed PDFs
8.24 Cryptography Policy-based crypto and key management AES-256-GCM with a random 96-bit IV per write and AAD binding (file:<id>.<kind>, field:<id>, token:<recipient>, tombstone:<doc>). Strict, fail-closed decryption, verified by swap, bit-flip, plaintext-file and plaintext-field tests. Atomic writes. Stored key-check value. HKDF-derived audit subkey with a public key id (lib/crypto.js:105-121) Partial No key id in the ciphertext header and no rotation. 0o600 is ignored on Windows (documented in code, operator must restrict the ACL). The key may sit beside the data
8.25 / 8.28 Secure development, coding SDLC, input validation, output encoding Input clamps. Signature PNGs are checked for strict base64, magic number and IHDR, at most 4000 px per side and 4 MP, and must decode with pdf-lib (lib/documents.js:271-287). Tested with malformed, header-only and oversized images. PDF magic, parse and 25 MB limit. HTML escaping in mail. Security regression tests Partial CSP is Report-Only for script-src. No CI, no npm audit, no SBOM
8.13 Backup Recoverable copies README covers volume backup and backing up the key separately (README.md:99,132) Operator Restore check

2. SOC 2 Trust Services Criteria (2017, rev. 2022)

Criterion How Countersign supports it Status To build
CC6.1 Logical access, encryption Hashed tokens and sessions. AES-256-GCM with strict decrypt and hash checks Partial Key management (rotation, off-host key)
CC6.2 / CC6.3 Provision, remove, least privilege Per-user isolation. Sign out everywhere Gap Admin user management, deprovisioning, roles
CC6.6 External threats Origin check, security headers, upload limits, account+IP and per-IP login locks, signup and password-check throttles Partial Enforced CSP, HSTS
CC6.7 Data in transit Secure cookie on https Operator / Partial HSTS header. Option to require SMTP TLS
CC6.8 Malicious software PDF parse check only Operator Malware scanning of uploads belongs to the operator
CC7.2 / CC7.3 Monitoring and anomaly evaluation Security events and audit heads on stdout Partial SIEM rules and head comparison are the operator's
CC7.4 / CC7.5 Incident response, recovery Sign out everywhere. Void revokes every link of a document Partial Admin "revoke all sessions/links" for a user. Audit export
CC8.1 Change management Maintainer collaboration rules (one owner per file, review before merge). Lockfile Partial CI with build, tests, npm audit and SBOM. Release tags
A1.2 Availability, backup Healthcheck (Dockerfile:24). WAL SQLite Operator Backup and restore guide including the key
C1.1 / C1.2 Confidentiality, disposal Encryption of files and field values. Delete for drafts, voided and declined documents Partial Metadata is plaintext. Retention and disposal settings
PI1.x Processing integrity State machine guards with re-checks under the write lock. Hashes re-verified on read. Signed PDF built before commit. HMAC chain Partial A verify endpoint for owners and a public hash check for a signed PDF
P-series (notice, consent, access, disclosure, quality) Emails state who sent the document Gap / Operator Signer data export and erasure, retention. Privacy notice is the operator's

3. CSA Cloud Controls Matrix v4 (STAR)

Domain Relevant controls Status Notes
IAM IAM-02 policy, IAM-05 least privilege, IAM-08 access review, IAM-14 strong auth, IAM-16 privileged access Partial Rate limits, timeouts, hashed secrets in place. No MFA, no admin, no email verification
CEK CEK-03 data encryption, CEK-10/11 key generation and management, CEK-12 key rotation, CEK-13 revocation Partial AES-256-GCM with a CSPRNG key and a key-check value. No rotation or key versioning
LOG LOG-02 audit logging, LOG-03 security monitoring, LOG-04 log access protection, LOG-08 log records, LOG-09 log protection Partial Keyed chain plus stdout anchors. Protection against a key holder depends on shipping logs off-host. Security events aren't chained
DSP DSP-07 data protection by design, DSP-10 sensitive data transfer, DSP-16 retention and deletion, DSP-17 sensitive data protection Partial No retention settings. No data-subject export or erasure
AIS AIS-04 secure development, AIS-05 testing, AIS-07 vulnerability remediation Partial Regression tests exist. No CI, SAST or dependency audit
TVM TVM-03 vulnerability remediation, TVM-07 detection Gap npm audit in CI. Pin next (currently ^16.4.3 in package.json, the lockfile pins it)

4. PCI DSS v4.0 (by analogy only)

Out of scope for cardholder data. Countersign doesn't collect, process, store or transmit PAN or sensitive authentication data. There's no payment feature (Permissions-Policy: payment=()). The operator must not upload documents containing cardholder data into Countersign and treat it as part of a CDE. The analogies below are used only as a hardening yardstick.

Req Analogy Status Gap
3 Protect stored data Encrypt documents at rest, manage keys Partial Key rotation, key off-host (env or KMS)
4 Encrypt in transit TLS everywhere Operator / Partial HSTS. Required SMTP TLS
6 Secure software 6.2 secure coding, 6.3 vulnerability management, 6.4.1 public web app protection Partial CI dependency audit. Enforced CSP
7 Restrict access Need-to-know Met (per user) Roles
8 Identify and authenticate 8.3.4 lockout after ≤10 tries, 8.3.6 12-char passwords, 8.4 MFA, 8.2.8 15-min idle timeout Partial The 12-character minimum is in place. Lockout holds under parallel requests (tested). MFA is missing. The default idle timeout is 60 minutes; set SESSION_IDLE_MINUTES=15 to match 8.2.8
10 Log and monitor 10.2 audit events incl. invalid logins, 10.3 protect logs, 10.5 retention Partial Invalid logins are logged. Log protection relies on off-host shipping. Retention is the operator's

5. APEC Privacy Recognition for Processors (PRP)

Processor obligation Software support Status To build
Security safeguards See sections 1 and 2 Partial Open findings below
Accountability (act on the controller's instructions, keep records) HMAC-chained per-document audit trail, stdout anchors, tombstones on delete, security-event log Partial Admin-action log. In-product verification against anchors
Breach notification support Security-event log. Sign out everywhere. Void revokes links Partial Audit export. Admin bulk revoke
Sub-processors Only optional SMTP (lib/mail.js). No telemetry (NEXT_TELEMETRY_DISABLED=1, Dockerfile:16). Fonts are self-hosted Met (software) Operator lists their SMTP provider and hosting provider
Return or deletion at end of service Volume copy. Per-document delete only for non-completed documents Gap Export of all of an account's documents and audit (zip). Account deletion with an audit tombstone

6. Findings

Closed (re-tested 2026-11-02; T3 and TW2 rows fixed and re-tested 2026-11-03, TW2-4 and TW2-5 by code review)

ID Was Verification
H1 Cross-owner signing links shown to unverified email Regression test passes. The attacker's /app and /app?view=action contain no victim token; the owner's own pending signature is still listed
M1 Plaintext passthrough and restart laundering Tests pass: a planted plaintext file is refused for owner and signer, completion refuses to stamp it, and the document stays sent. Correct-key ciphertext with different content fails the hash check and logs file_integrity_failure. A plaintext field value fails closed. By hand: with the marker present, a restart does not launder (residual risk in N3)
M2 Malformed PNG left completed without a signed PDF Tests pass for malformed, non-base64, header-only, 5000 px and 9 MP images. The document stays sent
M3 Lockout DoS Tests pass: the pair lock applies from the attacker's IP only, and the victim signs in from another IP
M4 Unkeyed, unanchored chain Tests pass: links are HMAC, not plain SHA-256. A chain rewritten without the key is reported broken. The certificate shows the Signed head, and Completed binds both hashes. audit_head lines match the DB and expose truncation
L1 Key file handling Windows limitation documented in code. POSIX mode warning, retry on an empty key file, key-check value
L2 Migrated tokens without expiry Migration sets an expiry on every token (lib/db.js:212). Checked by reading the code only
L3 Completion keeps links Accepted design, commented at lib/documents.js:347-349
L4 Signup oracle Per-IP signup throttle. scryptSync is still synchronous
L5 Unthrottled current-password check Test passes: 5 wrong attempts, then 429 even for the right password
L6 Typed email logged verbatim Test passes: a password-like identifier never appears in logs
N1 Concurrent final submissions deleted the signed PDF Test passes: of 6 parallel final submissions exactly one wins, the document is completed, the signed PDF downloads, and no candidate .tmp files are left
N2 Parallel login guesses bypassed the lock while the delay was active Test passes: a burst of 20 from one IP is held to the per account+IP limit
N3 Deleting the marker re-enabled laundering By hand: with the marker deleted and original_sha256 rewritten, a restart leaves the planted file in plaintext. Uploaded events carry the original SHA-256
L7 Key check ran lazily By hand: a wrong key makes the process exit 1 at boot with the key-check message
T1-1 OTP send cap was permanent Owner reminder resets otp_send_count and otp_attempts and logs it (lib/documents.js:605-606). Tested without SMTP: an undelivered reminder resets nothing. The full reset test needs SMTP: it passed in the QA Tier 1 run B with the SMTP sink (result reported by the orchestrator; not re-run by Raymond)
T1-2 Expiry race in submitSignature Re-check under the lock fails 410 (lib/documents.js:508-509). Code review only
T1-3 Pre-verification leak on queued, cc and waiting pages Test passes for queued signer and cc: title only, me: null, no fields, no Viewed event. Waiting and terminal states go through the same unverified branch (pages/sign/[token].js:37-40, code review)
T1-4 Literal ByteRange placeholder broke completion Test passes: 400 at upload, no document created. Residual in T1-10
T1-5 Remind on an overdue document Test passes: 409, expired, links revoked, token not extended, one Expired event
T1-6 Operator P12 checked once, bad P12 found only at first completion Test passes: garbage and expired P12 both exit 1 at boot. Per-seal re-check is code review only
T2-1 Owner-chosen redirect fired automatically after 5 s and allowed http: Verified by test in the 2026-11-02 runs: http: and non-string redirectUrl return 400, stored value unchanged. Click-only (no auto-redirect) on the sign page: code review only
T2-2 IPv6 verify limit per address Code review: the bucket key is the /64 for IPv6, also covered by unit asserts in scripts/tier2-selfcheck.mjs. Not testable from a loopback client. The shared-bucket risk behind a proxy without TRUST_PROXY is documented for operators instead of a boot warning
T2-3 Original match revealed the document status Verified by test in the 2026-11-02 runs: draft, sent and completed originals return exactly {match: 'original'}
T2-4 EMAIL regex accepted , < > " Verified by test in the 2026-11-02 runs: nine malformed addresses return 400 on contact create; a comma address in CSV is skipped. The same regex guards recipients (code review)
T2-7 No per-owner contacts cap Verified by test in the 2026-11-02 runs: five 2000-row imports reach 10,000; the next create returns 400 "You have reached the limit of 10,000 contacts"; an import adds nothing; another owner is unaffected
T2-9 Constant sealed: true in verify Verified by test in the 2026-11-02 runs: a signed match has exactly completedAt, match, signers, title
L8 Unsalted identifier hash in logs The identifier is now a keyed HMAC (lib/crypto.js:122). Checked by reading the code; a test confirms the raw value never appears in the logs
T3-02 Unbounded outbound webhook concurrency (80 connections measured) Fixed 2026-11-03 (runs coalesced on globalThis, loop while a batch is full). Verified in the orchestrator's run B by T3-02: webhook deliveries are coalesced, so an 80-row bulk opens at most 50 connections to one receiver
T3-03 Public-link visitor could make the server mail any typed address Fixed 2026-11-03: no signer completion email for a public-link copy without email codes; the done page offers the download. Verified in run B by T3-03: a completed public-link copy without email codes mails the owner, never the visitor-typed address
T3-04 Signer tokens returned to API-key callers Fixed 2026-11-03 (publicRecipient(r, !user.apiKeyId)). Verified in run B by T3-04: GET /api/documents/[id] gives signer tokens to the session owner only, never to an API key
T3-05 SSRF blocklist missed ::ffff:0:0/96, 2002::/16, 2001::/32, 100::/64 Fixed 2026-11-03. Verified in run B by webhooks (defaults): http, private, loopback, link-local and mapped literals refused at create (four new literals refused), plus scripts/tier3-selfcheck.mjs and a direct blockedAddress probe
T3-07 A public link stayed live after the signature field was removed Fixed 2026-11-03 (saveDraft runs the same send checks). Verified in run B by T3-07: removing the signature field from a template turns its public link off
T3-08 [action].js parsed any non-CSV body as JSON Fixed 2026-11-03: 415 "Send JSON or CSV". Verified in run B by T3-08 and T3-01: document actions take JSON or CSV only (415); bulk sends are limited to 10 per hour per user
T3-10 Per-template cap keyed on the template id, so rotation didn't restore it Fixed 2026-11-03: keyed on sha256(token). Verified in run B by public link: honeypot creates nothing; success gives only a signUrl; cross-origin POST blocked; per-template cap (rotation gives a fresh budget)
TW2-1 Medium: on a server with ALLOW_SIGNUP=false, any user could create a team and invite addresses they control, minting accounts Fixed 2026-11-03. The user chose "first account only": while sign-ups are closed, only the server's first account (earliest created_at, then id) may create a team (POST /api/teams gives others 403 "Only the server's first account can create teams while sign-ups are closed"), and invite sign-up is honoured only for a team that account owns. Anything else gives 403 "Sign-ups are closed on this server. Ask the admin for an account.", re-checked inside the sign-up transaction, so no user is created (lib/teams.js canCreateTeam, joinWithInvite({ signupClosed }); pages/api/auth/signup.js). Verified by TW2-1: on a closed server only the first account creates teams, and invite sign-up works only for teams it owns: a non-first account gets the 403 and no membership. An invite planted in a team that account owns is live, but sign-up gives the 403 with no session, no user and the invite unaccepted. Planted and API invites into the first account's team sign up (200). After the first account hands over ownership, its earlier invite gets the 403. In the orchestrator's teams-closed run (one open server, two fresh ALLOW_SIGNUP=false servers): 31 tests, 31 pass. By design, admins of the first account's team can still invite members who then sign up
TW2-2 Low: pending invites outlived the role of the admin who created them Fixed 2026-11-03: revokeInvitesBy runs in dropMember (removal and leave) and in setRole when the new role is member. Verified by TW2-2: removal, leaving and demotion to member revoke the pending invites that user created; accepted and others' invites stay (revoked_at set, join page and join 404; an already-accepted member stays; an untouched admin's and the owner's invites still join), teams-closed run
TW2-3 Low: GET /api/documents/[id] returned a template's publicUrl to read-only members and their keys Fixed 2026-11-03: publicUrl only when doc.owner_id === user.id. The owner's own API key keeps it, because it is the owner (orchestrator decision). Verified by TW2-3: GET /api/documents/[id] gives a template public link to its owner only; team writers, readers and their keys get null (team owner, admin and reader get null, with a session and with their keys, and the token text is absent), teams-closed run. QA's conflicting "same read view" assertion was changed to owner-only by the orchestrator
TW2-4 Low: sign-up ran scrypt inside BEGIN IMMEDIATE, holding the DB write lock Fixed 2026-11-03: hashPassword runs before tx() and createUser takes passwordHash (pages/api/auth/signup.js). Closed by code review; there is no test (latency only). L9, scrypt blocking the event loop, stays open
TW2-5 Info: the dashboard SSR sent teammates' folder_id Fixed 2026-11-03 (Steve, D2): folderId: mine ? d.folder_id ?? null : null (pages/app/index.js:65). Code review

Open

ID Severity Finding Where Owner
L9 Low Closed 2026-11-04: scrypt now runs async on the thread pool; login, sign-up and password change await it, and password change reserves its attempt first so parallel guesses cannot pass the limit (test/hardening.test.mjs). Was: scryptSync blocks the event loop on every login and signup. The throttles limit the volume but not the per-request stall. Fix: async crypto.scrypt lib/auth.js:13,20 Elon
L10 Low If the first legacy migration is interrupted part-way, the next start sees CSE1 files and skips the legacy pass. Plaintext PDFs left behind then fail closed (500) and need a manual re-run. Availability only, no laundering lib/db.js:132-141 Elon (document an operator re-run, or record per-file progress)
T1-10 Low The upload check (a regex for /ByteRange [0 /********** on the raw bytes) is narrower than @signpdf's parser, which takes the next [ after any /ByteRange whatever lies between (tested). By code review it also can't see a placeholder inside a compressed object stream, which sealPdf re-saves uncompressed (useObjectStreams: false); not tested. Repro: a stream containing /ByteRange (x) [0 /********** /********** /**********] uploads (201); every final submission then returns 422 and the document can't complete until the owner voids it. Owner-controlled input, fails closed, no mis-signing. Test T1-10 passes on the clean 422. Fix: at upload, re-save with pdf-lib (useObjectStreams: false) and reject when findByteRange from @signpdf/utils returns a byteRangePlaceholder pages/api/documents/index.js:15 Elon (route), Linus (helper in lib/pdfsign.js)
T2-5 Info CSV formula injection: contact names starting with =, +, -, @, tab or CR are stored verbatim. There is no export yet, so nothing is at risk today. When a contacts or audit CSV export lands, prefix those cells with ' lib/contacts.js:113-121 Elon (with the export)
T2-6 Info Confirmed Steve's flag: the saved-signature gate uses signerVerified, which is true when the document has no email code (lib/documents.js:374). On such documents the gate is "signed in with the recipient's email". Account emails are unverified (T1-7), so this doesn't prove the viewer is the recipient, but the only value ever offered is the viewer's own saved signature, never the sender's or another user's (tested cross-account). Acceptable as built pages/sign/[token].js:45-50 — (revisit with email verification)
T2-8 Info Email template bodies are free text from the sender (escaped). A sender can phrase a look-alike URL, as with the existing message field. The system button and link can't be removed (tested). Operator control: ALLOW_SIGNUP, SMTP sender reputation lib/mail.js:76-81 Operator
T1-7 Info Self-sign trusts the account email, which isn't verified at signup. It gives the owner nothing new (the owner already holds every link of a document without a code) and can't skip the code for anyone else (tested) lib/documents.js:240 Elon (email verification, P1)
T1-8 Info Secure on cs_otp_* follows appUrl(req). Behind a TLS proxy without APP_URL or TRUST_PROXY the cookie is sent without Secure lib/documents.js:411-412 Operator (set APP_URL)
T3-01 Low (was Medium) Mitigated 2026-11-04: per-owner storage quota on original PDFs (STORAGE_QUOTA_MB, default 1024, checkQuota in lib/documents.js), checked on upload, copy, public-link copy and up front for a whole bulk send; 413 past it (test/hardening.test.mjs). Originals are still stored once per copy, because each file is encrypted with its document id as AAD. Was: Partly fixed 2026-11-03. Done: the template is read and verified once per bulk send (lib/documents.js:329), bulk sends are limited to 10 per hour per user (verified in run B by T3-08 and T3-01: document actions take JSON or CSV only (415); bulk sends are limited to 10 per hour per user), and a copy whose DB insert fails deletes its PDF (lib/documents.js:852-853, code review). Still open: every bulk row and public-link copy stores its own encrypted copy of the original (measured before the fix: 103 files for 20 distinct PDFs), and there is no per-user storage quota. Fix: store each original once by original_sha256 with a reference count (Elon schema proposal), or add a per-user storage quota lib/documents.js copyDocument Linus (engine), Elon (schema)
T3-06 Info Closed 2026-11-04: keys can expire (30/90/365 days or never; Settings defaults to 90), "Sign out of all devices" can revoke every key ({ apiKeys: true }), and POST /api/webhooks/[id]/secret rotates a signing secret (session only, owner only). A password change revokes every key, and a key is read-only or read and write, enforced in api() (test/hardening.test.mjs). Was: API keys have no expiry or scopes and survive logout-all and password change. Webhook secrets can't be rotated except by delete and re-create. Fix (later): list active keys on the sessions page and offer "revoke all keys" with logout-all. Add POST /api/webhooks/[id]/secret for rotation lib/apikeys.js, lib/webhooks.js Elon
T3-09 Info Closed 2026-11-04 (docs): README TRUST_PROXY row now names the public template link limit. Was: Behind a reverse proxy without TRUST_PROXY=true, every public-link visitor shares the proxy's 10/hour bucket pages/api/public-templates/[token].js:20 Operator
T1-9 Info The seal isn't PAdES-B-T/LTV: no trusted chain, no timestamp. An incremental update after sealing is visible only as "changed after signing" in a reader, or through signed_sha256 lib/pdfsign.js Linus (P2)
TW2-6 Info Mitigated 2026-11-04: server-wide cap on invite emails (INVITE_EMAILS_PER_HOUR, default 100); past it the invite is created and the link shown to copy, not emailed. Covered by test/hardening.test.mjs when the mail sink is on. Was: Invite emails carry an attacker-chosen user name and team name in the subject and body (escaped, CR/LF stripped). On an open server any account can send up to 30 per hour to any address through the operator's SMTP: a phishing and spam relay like the existing document send (T2-8) pages/api/teams/invites/index.js:25-32 Operator, Elon (later: per-server invite cap)
TW2-7 Info Closed 2026-11-04: Developer settings copy says a key acts with the user's team role. Was: Document routes accept API keys, and a key carries its user's team rights: a member's key reads every team document, and an admin's key can send, void and delete members' documents. That follows the contract, but keys have no scopes (T3-06). Document this in the developer settings pages/api/documents/** Elon, Steve (copy)
TW2-8 Info Closed 2026-11-03: shareDocument moved to lib/documents.js and writes "Shared with team" / "Made private" in the same transaction as the change; a repeat writes nothing (test/teams.test.mjs, share action). Was: Sharing or unsharing a document (share action) writes no document audit event and no security-log entry, so the audit trail doesn't show when a document became visible to a team or stopped being visible. Fix: in shareDocument, add insertEvent(doc.id, user.name, shared ? 'Shared with team' : 'Made private', { req }) (Linus helper, routed through the engine), or a logSecurity('document_shared', …) from the route lib/teams.js:193-201, pages/api/documents/[id]/[action].js (share) Elon, Linus
F-1 Info Flaky test, not a product finding yet: test/security/p0.test.mjs "audit chain: an edited event is reported as broken on the certificate" failed once in the orchestrator's post-fix run A (extracted pdfText was only 'doc c755de66fd87\n') and passed 5/5 on a rerun against a fresh server. Likely timing or text extraction in the test; not investigated. Track until it is explained test/security/p0.test.mjs:503 Raymond (investigate), QA
SSO-1 High Closed 2026-11-04: Linking by email now needs a live session on that account; everyone else is sent to sign in and connect from Settings. Was: Pre-account hijack through the email link. Sign-up does not verify addresses, so an attacker signs up with the victim's address and a password. When the victim first uses SSO they are linked into that account. Measured after the link (todo test): the attacker's password gives 200, their old session is live, their API key gives 200, their webhook is active, and an accomplice they made team admin reads the victim's new document (200), because new documents join the owner's team. Also reproduced on closed servers: an account made while sign-ups were open and an account minted through an invite. Fix: link an existing account only when the callback's browser has a live session for that same account. Otherwise refuse, and offer a "Connect" link in Settings lib/oidc.js:126-134, lib/documents.js:176 Elon (lib/oidc.js, callback), Steve (Settings link), QA (test/oidc.test.mjs:129-135)
SSO-2 Medium Partly closed 2026-11-04, confirmed by second review: OIDC_REQUIRED refuses password sign-in for connected accounts and password sign-up, including invites (measured). Still open: sessions and API keys outlive removal at the provider, and accounts never connected keep their password. Those need an admin way to disable a user. Keys are not revoked on connect, since the owner is the one connecting; see SSO-23 for what that leaves. Was: Turning someone off at the provider does not end their access here. A linked account keeps its password, which also skips any provider MFA. API keys and sessions carry on too, and keys may have no expiry. There is no admin way to disable a user. Partly documented in the README. Fix: an OIDC_REQUIRED setting that refuses password sign-in for accounts with an identity, and revokes their keys on link. Disabling a user needs the admin role in section 7 pages/api/auth/login.js:25, lib/apikeys.js:65 Elon
SSO-3 Low 2026-11-05: the server warns once when X-Forwarded-For arrives without TRUST_PROXY. The risk stays with a wrong setting. Was: Partly closed, third review 2026-11-05: 60 exchanges a minute per ipPrefix with a one-minute lock, counted before any provider request (measured: 60 requests reach the provider, later hits write no rows, and the address is free again after 61 s). Still open: a script with no account re-locks an address in about 400 ms, so it can keep holding it. That is fair for a shared address, but behind a proxy without TRUST_PROXY the address is every user, and with OIDC_REQUIRED connected accounts have no other way in. Nothing warns about that setting. Fix: warn once when X-Forwarded-For arrives without TRUST_PROXY. Was: Partly closed, second review 2026-11-04: the no-cookie case holds (measured: no row, no bucket). The lockout does not: anyone can get a valid state from start, so a script with no account sent 200 start and junk-code pairs in 939 ms, made 200 token requests to the provider, and the next real person at that address got /login?error=sso with no session (measured). The lock lasts an hour, and limited hits leave no row, so the operator sees nothing. Behind a proxy without TRUST_PROXY that address is every user, which is the original finding. Password sign-up was unaffected (200). The comment at lib/oidc.js:164 is wrong. Fix: count failed exchanges only, in a short window (about 30 a minute, a one-minute lock) so that real sign-ins never count, log once when the lock is set, and warn once when requests arrive with X-Forwarded-For and no TRUST_PROXY. The earlier text, for the record: No limit on start. A callback with no flow cookie makes no request and no log row. Only exchanges with a matching state count, per ipPrefix, and limited hits are not logged. Real exchanges from one address can still fill the bucket, so TRUST_PROXY stays needed behind a proxy. Was: One bucket per IP, shared by start and callback, with a one-hour lock. 205 cookie-less callback hits locked out the real user at that address (measured: 194 login_failure rows, then 11 more while locked). Behind a proxy without TRUST_PROXY that is every user on the server. Password sign-in was unaffected (200). IPv6 is not keyed on the /64. Each hit also writes a login_failure row, even while locked. Fix: drop the limit on start, ignore callbacks with no flow cookie (no count, no row), count only failed code exchanges, key on ipPrefix, and do not log rate-limited hits lib/oidc.js:164-165, lib/auth.js:82, pages/api/auth/oidc/callback.js:25 Elon
SSO-4 Low Closed 2026-11-04: With no users, SSO creates an account only when a domain list is set. README says to create the first account before exposing the server. Was: While a server has no users, anyone the provider vouches for becomes the first account, even with ALLOW_SIGNUP=false. Measured with no domain list and OIDC_TRUST_EMAIL=true: an unverified foreign address became first account, created a team and minted a password account for another address. Password sign-up has the same first-visitor race, so this is a second door rather than a new class. With OIDC_AUTO_CREATE the first employee to sign in gets these rights (code review). Fix: at zero users, SSO creates an account only with a domain list; the README tells operators to create the first account before exposing the server lib/oidc.js:120, lib/auth.js:226, lib/teams.js:42 Elon, orchestrator (README)
SSO-5 Low Closed 2026-11-04: Over-long addresses are refused, not cut. Was: str() cuts the email at 254 characters before the domain check. Measured: aaa...@allowed.test.evil.test becomes aaa...@allowed.test, passes the list and gets an account. The provider must vouch for an address over 254 characters. Fix: refuse a claim over 254 characters instead of cutting it lib/oidc.js:111,115 Elon
SSO-6 Low Closed 2026-11-04: safePath takes printable ASCII only. Was: A next outside Latin-1 makes res.redirect throw ERR_INVALID_CHAR outside the try: the user gets a 500 (measured). By code review the session cookie is already set. Fix: in safePath, refuse anything outside \x21-\x7e pages/api/auth/oidc/callback.js:25, lib/oidc.js:25-28 Elon
SSO-7 Low Closed 2026-11-04: Object.hasOwn. Was: SSO_ERRORS[query.error] reads inherited keys. /login?error=__proto__ gives a 500 from any link, with or without OIDC settings (measured). Fix: Object.hasOwn pages/login.js:12 Steve
SSO-8 Low Closed 2026-11-04: signup and sso_linked events added. Was: SSO account creation and identity linking write no event of their own (measured: only login_success, detail sso). Password sign-up logs signup. Fix: signup and sso_linked events lib/oidc.js:132-134, pages/api/auth/oidc/callback.js:16 Elon
SSO-9 Medium Closed 2026-11-05, fourth pass: public copies have their own bucket the size of STORAGE_QUOTA_MB. A full bucket stops only the link, and an account holds at most twice the quota. Expired declined copies are kept; deleting them is a retention decision for the owner of the project. Was: Reopened, third review 2026-11-05: public-link copies no longer count toward the owner quota, so a visitor cannot fill it, but the quota no longer bounds them and the 50-a-day cap is a rate, not a bound. Measured under a 30 MB quota: 12 different 14 MB files, each uploaded as a template, submitted once, declined, and the template deleted, left 168 MB kept in 12 s, every copy readable, and uploads still allowed. One address gets 10 submits an hour; a new token gets a fresh link budget (measured). At the 25 MB upload limit that is 6 GB a day per address. Measured for visitors, with no owner action: 50 declined copies of a 5 MB template added 260 MB and the job never removes them, so one 25 MB template can grow 1.25 GB a day, with no ceiling. Fix: a separate bucket for public copies, the same size as the quota, so a full bucket stops only the link, and delete declined copies once expired. test/security/oidc-round2.test.mjs (todo). Was: Not closed, second review 2026-11-04: the job does what it says and deleted only what it should (measured: kept declined, completed, voided, partly signed, not yet due, bulk copies, ordinary documents with the marker text, and the template). The quota finding stands, because a visitor chooses whether a copy is abandoned. Signing or declining takes one request, those copies are never cleaned, and they stay charged to the owner for good. Measured with STORAGE_QUOTA_MB=1: 3 copies of a 250 KB template, 2 declined and 1 signed, survive the job and the owner's next upload gets 413. Abandoned copies also still count until they expire, so 50 copies a day of a 25 MB template exceeds the default quota before the first one is deleted. Fix: do not charge public-link copies to the owner's upload quota, or give them a separate cap per template so that only public links stop working, and delete declined copies too. What the job added: expired public-link copies nobody signed are deleted, keeping a tombstone, and the owner no longer gets an expiry email for them. Was: Public-link copies are charged to the template owner, so visitors can use up the owner's quota: 50 copies a day of a 25 MB template is 1.25 GB, over the default 1024 MB. New uploads then get 413 until the owner deletes copies. Code review, not run. Fix: delete expired, unsigned public-link copies in the hourly job lib/documents.js:317,533-535, pages/api/public-templates/[token].js:21,27 Linus
SSO-10 Info Closed 2026-11-04: Sign-up page hides the button when SSO could not create the account. Was: An invited person on a closed server sees the SSO button on /signup?invite=, but without OIDC_AUTO_CREATE SSO refuses them, because the invite is not passed to the callback. Fails closed. Code review. Fix: hide the button unless they can be created pages/signup.js:13, lib/oidc.js:131 Steve
SSO-11 Info Closed 2026-11-04: OIDC_ALLOW_HTTP is ignored when APP_URL is https. Was: OIDC_ALLOW_HTTP=true also applies with an https APP_URL, and then the ID token's signature check is skipped without TLS. Fix: ignore the setting when APP_URL is https lib/oidc.js:33 Elon
SSO-12 Info Closed 2026-11-04: azp is checked whenever present. Was: azp is checked only with several audiences. OIDC Core says to check it whenever present. No known attack here. Fix: also check it when claims.azp is set lib/oidc.js:100 Elon
SSO-13 Info Closed 2026-11-04: password change and logout-all with { apiKeys: true } pause every webhook, with a webhooks_paused event. A plain logout-all leaves them, as it does keys. Was: Password change and logout-all leave webhooks active, so a webhook planted during a compromise keeps receiving titles and recipient emails. Fix: list them in the confirmation message, or pause them with { apiKeys: true } pages/api/account.js:32-33, pages/api/auth/logout-all.js:11-12 Elon
SSO-14 Info Closed 2026-11-04: Read-only keys get publicUrl: null. Was: A read-only key receives the owner's publicUrl, which anyone can use to create a signing copy. Code review. Fix: return it only without apiKeyScope === 'read' pages/api/documents/[id]/index.js:13 Elon
SSO-15 Info The flow and session cookies have no __Host- prefix, so a sibling subdomain could plant them. A planted flow cookie with its matching state and code gives login CSRF. The flow cookie cannot take the prefix while its Path is not /. Code review. Operator control: no untrusted sibling subdomains pages/api/auth/oidc/start.js:13, lib/auth.js:169 Elon, Operator
SSO-16 Info Closed 2026-11-05: the first review of password reset reopened this (RST-8), and connected accounts now get no reset, so it fails closed again. Closed 2026-11-04: Documented as a known limit in the README; fails closed. Was: The account follows sub and users.email is never updated. After a rename at the provider, invites and signer matching still use the old address, and whoever later gets that address is refused with no account. Fails closed. Code review. Fix: update the email on sign-in when it is verified and free, or document it lib/oidc.js:125 Elon
SSO-21 Medium Closed 2026-11-05, see RST-7: hasIdentity checks the current issuer, so accounts with a password can sign in and reconnect, and accounts created by SSO get a password through password reset, with OIDC_REQUIRED off. Was: A changed OIDC_ISSUER locks accounts out, and the product has no way back. Measured with the same subjects and data on a new issuer, OIDC_REQUIRED=true: a connected account gets /login?error=sso_link from SSO and 403 from password sign-in, and the message tells them to sign in with a password. An account created by SSO gets the same refusal with any OIDC_REQUIRED value, because nobody knows its random password and there is no reset (code review for the false case). This came with the SSO-1 fix, which removed the email link that used to carry people over. The README does not mention it. Trigger: moving tenant or provider. Fix: hasIdentity checks the current issuer so a known password works again, and the README gets a migration runbook. Accounts created by SSO need the admin role in section 7 lib/oidc.js:135,139-147, pages/api/auth/login.js:32 Elon, orchestrator (README)
SSO-17 Low Closed 2026-11-04: a flow with a live session never switches accounts; it reports the failure in Settings. Was: Connect can silently move the person to a different account. Measured from a live session in Settings: a provider address with no account here creates a second account and switches the session to it; a provider identity tied to another account switches to that account. Both end at /app with no error, and the "That didn't connect" message never shows. The first case needs open sign-ups or OIDC_AUTO_CREATE. With OIDC_REQUIRED the person believes their password is off when it is not. Fix: carry the connecting user id in the flow from a Connect request; a callback for that flow links that account or fails, and never creates or switches. test/security/oidc-round2.test.mjs (todo, 2 tests) lib/oidc.js:140,149-150, pages/api/auth/oidc/callback.js:17-18 Elon
SSO-18 Low Closed 2026-11-04: bad flow cookies and states write no row, and other failures are behind the limit. Was: The SSO-3 log flood remains for any request with a cookie. Measured: 25 callbacks with Cookie: cs_oidc=junk wrote 25 login_failure rows, with no limit and no account. A real flow cookie with a wrong state, or a right state with error=, also writes a row before the limit is checked (code review). Rows grow the database and bury real failures. Fix: treat a cookie that does not decrypt like no cookie, and move the query.error check after the limit. test/security/oidc-round2.test.mjs (todo) pages/api/auth/oidc/callback.js:11-12,25, lib/oidc.js:78-80,162 Elon
SSO-20 Low Closed 2026-11-04: each document has its own try. Was: One bad document stops the cleanup job. deleteExpiredPublicCopies has no per-document try, so a throw ends the loop. Measured: with one unreadable field value on the first row, the job threw Value is not encrypted and both copies stayed. Nothing removes that row, so by code review it can block the rest every hour, and the log line names no document. A failed rmSync after the commit also ends the loop and leaves an orphan file that nothing retries (code review). Fix: try/catch around each document, logging its id. test/security/oidc-round2.test.mjs (todo) lib/documents.js:536,926-928 Linus
SSO-22 Low Closed 2026-11-04: linking needs a flow begun by same-origin POST /api/auth/oidc/connect for that same user. Was: Connect needs no consent. start is a plain GET, so a link on another site starts it. Measured with Sec-Fetch-Site: cross-site and a logged-in browser: the account was connected, and with OIDC_REQUIRED its password then gave 403, so the warning in Settings is skipped. A provider session that signs in without a prompt makes this one click. The identity must still carry the account's own email, so this is not a takeover by itself; a provider with its own login CSRF would make it one (not tested). Fix: start Connect with a same-origin POST from Settings, the same request as SSO-17 pages/api/auth/oidc/start.js:5-12, lib/oidc.js:147 Elon, Steve
SSO-23 Low Closed 2026-11-05 for sessions: the callback ends this browser's old session too. Keys and webhooks stay, and Settings lists them. Was: Partly closed 2026-11-04, third review 2026-11-05: connecting under OIDC_REQUIRED ends other devices' sessions (measured). The session the password opened survives: the callback spares its cookie and then replaces it, so its row answers 200 until idle or maximum expiry, and the person can no longer sign it out (measured). Fix: end that row too, before startSession. test/security/oidc-round2.test.mjs (todo). Keys and webhooks stay, and Settings lists them. Was: With OIDC_REQUIRED, connecting leaves everything from the password era running. Measured after a connect: a second device's session 200, an old API key with no expiry 200, a webhook active. These skip provider MFA, and the password refusal suggests the account is now SSO only. Not revoking keys on connect is reasonable, because it would break automation with no warning. Other sessions are different: they break nothing and are a single call. Fix: end other sessions on connect when OIDC_REQUIRED is on, and show the count of live keys and webhooks in Settings after connecting lib/oidc.js:152, pages/api/auth/oidc/callback.js:18 Elon, Steve
SSO-24 Low Closed 2026-11-04: the server warns at boot. Was: OIDC_REQUIRED turns itself off without a word when the other settings are incomplete. Measured with OIDC_REQUIRED=true and an empty client secret: a connected account's password gave 200, a new password sign-up gave 200, and the server log has no mention of it. Failing open avoids a lockout, so the behaviour may stay; the silence should not. Fix: a startup error when OIDC_REQUIRED=true and OIDC is not fully configured lib/oidc.js:24, instrumentation.js:4 Elon
SSO-28 Low Closed 2026-11-05: the server warns at boot when identities exist for another issuer under OIDC_REQUIRED. Was: A changed OIDC_ISSUER silently turns passwords back on under OIDC_REQUIRED, as a side effect of the SSO-21 fix. Measured: a connected account gives 403 on a server, and 200 on a copy of its data with only a trailing / added to the issuer, with no log line. With that / discovery can also fail, so SSO can be down while old passwords work. Fix: warn at boot when oidc_identities has rows for another issuer lib/oidc.js:139, instrumentation.js:5 Elon
SSO-19 Low Closed 2026-11-04, confirmed by third review: recordLoginSuccess runs before the refusal. Was: A correct password refused by OIDC_REQUIRED counts as a failed guess, because the attempt reserved at line 24 is only undone by recordLoginSuccess. Measured: 403 four times, then 429. The per-IP counter is not refunded either, so by code review 50 such attempts from one office address lock password sign-in for unconnected accounts at that address. Fix: call recordLoginSuccess before the refusal. test/security/oidc-round2.test.mjs (todo) pages/api/auth/login.js:24,32-35 Elon
SSO-25 Info Closed 2026-11-04: an unreadable value means the default. Was: STORAGE_QUOTA_MB with a value that is not a number, such as 1GB, gives NaN and turns the quota off. Empty now means the default, as intended. Code review, not run. Fix: treat only a literal 0 as off lib/documents.js:52-53 Linus
SSO-26 Info The cleanup job leaves little trace. It writes no security log row and no webhook event, and it has no grace period, so copies that expired before this change go on its first run. Nothing reads audit_tombstones (only the insert exists), so the kept trail needs SQL and the key. A signer's old link becomes "link not found" rather than "expired". The job first runs an hour after boot, so a server restarted more often than that never runs it. Code review. Fix: log a count per run, and run once at boot lib/documents.js:532-537, instrumentation.js:23 Linus, Elon
SSO-27 Info Closed 2026-11-04: the README says events during a pause are not sent later. Was: Events during a webhook pause are dropped, not held, because emitEvent queues only for active webhooks. Deliveries queued before the pause are sent on re-enable, even if old. A routine password change therefore stops an integration with no email to the owner. This is a fair price for the control, but the README says only "pause". Code review. Fix: say so in the README and Settings lib/webhooks.js:160,243 Elon, orchestrator (README)
RST-2 High Closed 2026-11-05: login and password change read the hash again after their waits and refuse if it changed. Was: A sign-in already in flight survives a reset. Login reads the hash, waits (growing delay, then scrypt), and starts a session without looking again, so someone who knows the old password keeps a session after the person resets because of them. Measured with a steady 40 sign-ins a second and nothing else: 2, 2, 2, 2 and 4 sessions still live after the reset, in 5 of 5 trials. Measured with 30 wrong guesses first, which holds the old hash for 5 seconds: the sign-in begun before the reset answered 200 and its session answered 200, while a fresh sign-in with the old password got 401. A session can make new API keys (code review). The same gap exists in a password change (code review) and has been there since scrypt became async (L9). Fix: after the await and before startSession, read password_hash again and refuse if it changed. Do the same in account.js before its UPDATE, or a slow change can overwrite the reset. test/security/reset.test.mjs (todo) pages/api/auth/login.js:13,26,37, pages/api/account.js:22,32 Elon
RST-7 Medium Closed 2026-11-05: with OIDC_REQUIRED, resetRefused covers identities at any issuer, with no new setting. To change provider the operator turns OIDC_REQUIRED off for the move; during that window the mailbox of an account not yet reconnected can still reset, and the README says so. Was: Under OIDC_REQUIRED, a changed issuer lets anyone with the mailbox make a password. SSO-28 covered old passwords coming back. Reset goes further: accounts made by SSO had no usable password, and now get one. Measured: account made by SSO, identity moved to an old issuer, forgot mails a link, reset 200, password sign-in 200. This also applies to someone not carried over to the new provider, and it stays open for them for good, because they never reconnect. The boot warning says "until they connect again". The README says accounts made by SSO get a password this way, but not that it holds for people left behind. This is what closing SSO-21 costs. Fix, for the owner of the project to choose: a time-boxed OIDC_ISSUER_MIGRATION setting that allows reset for old-issuer accounts, refused otherwise; or keep today's behaviour and say so in the README. test/security/reset.test.mjs (todo, asserts the first option) lib/oidc.js:140, pages/api/auth/forgot.js:20, pages/api/auth/reset.js:17, instrumentation.js:17 Elon, orchestrator (README, decision)
RST-8 Medium Closed 2026-11-05: resetRefused gives accounts connected to the current issuer no link, with or without OIDC_REQUIRED. Cost: no reset for them while the provider is down. Was: An address reassigned at the provider can take the old account. users.email keeps the first address (SSO-16). Measured without OIDC_REQUIRED: account made by SSO, the same subject renamed at the provider, a new person given the old address. SSO refuses them, but forgot mails them a link: reset 200, password sign-in 200, and the first person's session is ended. It is the same account row, so they hold that person's documents. Fix: without any setting, send no link to an account with an identity on the current issuer whenever OIDC is on, using oidcEnabled() in place of oidcRequired() in both routes. This also closes RST-10 for such accounts. Its cost: a connected account has no reset while the provider is down. Also update the email on sign-in when it is verified and free. test/security/reset.test.mjs (todo) pages/api/auth/forgot.js:20, pages/api/auth/reset.js:17, lib/oidc.js:146-148 Elon
RST-10 Medium Closed 2026-11-05: closed for accounts connected to the current issuer, as RST-8. Without OIDC_REQUIRED, an account connected only to an earlier issuer can still reset, which is how SSO-21 recovers. Was: Without OIDC_REQUIRED, someone removed at the provider can get back in through reset. Before this change an account made by SSO had a random password nobody knew, so removal stopped new sign-ins. Measured on a server with OIDC and no OIDC_REQUIRED: account made by SSO, then with nothing more from the provider, forgot mailed a link, reset 200, password sign-in 200. The README's parts add up to this, but it never says a person removed at the provider can get back in. The comment at lib/oidc.js:181 ("signs in by SSO only") is now wrong. It needs a mailbox that outlives the provider account, such as an outside address or a forward. Fix: the RST-8 rule, and say this plainly in the README. Hand run pages/api/auth/forgot.js:20, lib/oidc.js:181, README.md Elon, orchestrator (README)
RST-1 Low Closed 2026-11-05: the work starts at a random moment up to 3 seconds after the answer. Not measured over a real network. Was: The request after a forgot shows whether the account exists. The answer itself is even, but the work after it blocks the event loop for about 2 ms for a known address only. Measured on loopback, the next request took 3.81 against 1.72 ms (z 8.66), 4.96 against 1.97 ms with a hung SMTP server (z 5.98) and 3.56 against 1.66 ms with SMTP refused (z 9.05). A best threshold chosen after the fact sorted 85% of 80 addresses from one sample and 89% from three. Three is the most an address gives per hour, each known try mails the owner, and each address or /64 gets 20 tries an hour. Real network jitter was not measured and should make this harder. Fix: wait a random 0-3 s before sendLink, so a probe can't line up with the work. test/security/reset.test.mjs (todo) pages/api/auth/forgot.js:45 Elon
RST-3 Low Closed 2026-11-05: a reset revokes pending invites the account created. Members who already joined stay. Was: A reset leaves team changes made by the intruder. Measured: an admin invite made before the reset let the invited address join after it. People who already joined stay too (code review), as do public template links and the webhook targets, which are paused rather than removed (code review). The reset page lists what it ends but not this. Fix: revoke pending invites this user created, and name the team and public links in the reset mail so the owner can check them. test/security/reset.test.mjs (todo) pages/api/auth/reset.js:25-28 Elon, Steve (copy)
RST-4 Low Closed 2026-11-05: the steps of a reset are one transaction. Its stdout log lines are written before the commit, so a rolled-back reset can leave a line behind. Was: A reset that fails midway leaves API keys alive. The steps are separate statements. Measured with a write forced to fail at key revocation: answer 500, old password 401, API key 200, same link 400, so the person sees an error, the link is gone and the key still works. A new link finishes the job, if they ask for one. Fix: one tx around spending the link through pausing webhooks. The hash is already computed outside it. test/security/reset.test.mjs (todo) pages/api/auth/reset.js:21-29 Elon
RST-5 Low Closed 2026-11-05: the owner gets an email when mail is on. Without mail nobody is told. Was: Nobody tells the owner their password was reset. Measured: no mail after a completed reset. Someone with brief mailbox access can delete the link mail, and the owner learns only at the next sign-in. Fix: a mail with no link, sent after the answer. test/security/reset.test.mjs (todo) pages/api/auth/reset.js:29 Elon
RST-9 Low Closed 2026-11-05: the script stops with a clear message when no database exists. Its event still reaches the table but not the stdout JSON line. Was: A wrong DATA_DIR leaves an empty database behind. Measured: on an existing folder the script creates countersign.db and then stops with a raw error and stack trace. A missing folder also gives a stack trace. The script's event reaches the table but not the JSON line on stdout that logSecurity writes, so log shipping misses it (code review). Fix: check the file exists and print one line, and print the same JSON line. test/security/reset.test.mjs (todo) scripts/reset-link.mjs:14,25 Elon
RST-11 Low Closed 2026-11-05: EMAIL refuses :. Existing rows are not changed. Was: Mail group syntax gets round the 3 an hour with new accounts. EMAIL accepts a colon and the mailer reads it as a group. Measured: accounts team:x@example.com and team2:x@example.com both registered, and both reset mails reached x@example.com. It needs open sign-ups, and gives at most 3 mails per account within the sign-up limit. Plus addressing does the same where the mail provider supports it (not run). Other places that use EMAIL were not checked. Fix: refuse : in EMAIL. test/security/reset.test.mjs (todo) lib/http.js:72, pages/api/auth/forgot.js:15 Elon
RST-6 Info A reset clears the account's delay and the lock for the address that used the link, not locks at other addresses, so the owner's laptop can stay locked for up to 15 minutes (code review; the guesser's lock is the same key). This is reasonable as built: clearing it would give a guesser more tries. Measured: a guesser's lock survives the reset. ok: rows also survive, so an address that once signed in keeps its exemption from the per-address lock (code review). No change proposed; the wording "clears lockouts" is wider than this lib/auth.js:178-182 Elon
RST-12 Info The token stays in the address bar and browser history after /reset loads, and is not removed after use. It is spent on use, so this matters only for a link left unused for up to an hour on a shared computer. Code review, no browser run. Fix: history.replaceState once it is read pages/reset.js:15 Steve
RST-13 Info Behind a proxy without TRUST_PROXY, all clients share one bucket, so 20 requests an hour from anyone stops reset for everyone for an hour. The existing warning covers the setting. Code review; the limit itself was measured pages/api/auth/forgot.js:36 Operator

7. Prioritised build list

Sizes: S ≤ 1 day, M ≤ 1 week, L > 1 week. Owners follow the maintainer file-ownership rules. Schema changes go through Elon and need orchestrator approval first.

P Item Evidence (current code) Size Owner
P1 Owner-facing verify endpoint: recompute the chain and compare it with a supplied or logged head lib/documents.js:111-119 S Linus, Elon
P1 Password blocklist (NIST 800-63B). Operator re-run path for an interrupted first migration (L10) lib/auth.js:11-37, lib/db.js:132-141 S Elon
P1 Optional TOTP MFA for senders, plus recovery codes — M Elon (+ Steve)
P1 Enforced CSP with per-request nonces next.config.mjs M Elon (+ Steve for _document)
P1 HSTS header when APP_URL is https. SMTP_REQUIRE_TLS option next.config.mjs, lib/mail.js:8-13 S Elon
P1 Audit export (CSV/JSON) per document and per account, including the security log — S Elon (route), Steve (button)
P1 Key rotation: key id in the header (CSE2), keyring env, re-encrypt job lib/crypto.js:6,56 M Elon (+ Linus for callers)
P1 Admin role: list, disable and delete users, revoke sessions and links, with admin actions logged lib/auth.js:183-186 M Elon (+ Steve)
P1 Email verification (prerequisite for any cross-owner feature) pages/api/auth/signup.js M Elon (+ Steve)
P1 Retention and deletion: operator retention period, purge job that keeps the tombstone, account export and deletion, signer PII erasure lib/documents.js:453-468 M Linus (lifecycle), Elon (job, schema)
P1 Signer email code: built (section 0b), T1-1 and T1-3 fixed. The full T1-1 reset test passed against the SMTP sink in the QA Tier 1 run B test/security/tier1.test.mjs S QA
P1 CI: build, npm test against a throwaway server, npm audit --omit=dev, npm sbom no .github/ S Orchestrator, Elon for scripts
P2 Organisations and RBAC documents.owner_id L Elon, Linus, Steve
P1 Before SSO ships: SSO-9 public copy bucket, SSO-23 password session. Then the SSO-3 TRUST_PROXY warning and the SSO-28 boot warning section 0f S Elon, Linus
P2 SAML (OIDC is built, section 0f) — L Elon
P2 PAdES-B-T/LTV: a CMS seal exists (self-signed or operator P12). Still missing: RFC 3161 timestamp, trusted chain, LTV data lib/pdfsign.js L Linus
P2 External KMS or HSM key provider lib/crypto.js:24-47 M Elon

8. Operator responsibilities (software cannot cover)

Running Countersign doesn't make an organisation compliant. The operator owns the ISMS scope, policies, risk assessment and treatment, and Statement of Applicability. They also own engaging the auditor or CPA firm (SOC), the CSA STAR registry submission and the APEC PRP Accountability Agent. Beyond that: HR security (screening, training, offboarding), vendor and sub-processor management (hosting, SMTP), physical and environmental security, and BCP/DR with tested restores (database, files/ and the encryption key, backed up separately). Also: setting ENCRYPTION_KEY from a secret store rather than relying on the key file (otherwise the audit HMAC key also sits beside the data), restricting the data directory's ACL on Windows hosts, setting TRUST_PROXY correctly behind a reverse proxy, TLS termination with HSTS, host hardening and patching, NTP, network controls and WAF, malware scanning of uploads, log retention and SIEM (including shipping the JSON security events and audit_head lines off-host and comparing heads, which is the only defence against truncation by a key holder), incident response and breach notification to controllers or regulators, privacy notices and lawful basis, data-subject request handling, and legal review of whether these electronic signatures are suitable for each document type and jurisdiction.