diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae53c0abc..e476aad1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,18 +62,78 @@ jobs: # messenger_test, which pins the messenger's "no server plaintext" claim by # asserting its script has no way to reach the network. # + # lib/, romania/tests/, and the colocated route tests pin delivery, + # consent, session-key injection, contact rate-limit identity, gate + # fail-closed, JWT expiry, and GPC/DNT. They were passing locally (or + # living under tests/, which CI cannot run) and running nowhere. + # --allow-write is for keystore/protect temp dirs; --allow-run is for + # email_test's python3 sender and romania's binary probes. Tests that + # mutate process env rely on Deno's default sequential run (do not pass + # --parallel). Do not widen to tests/: that directory still carries the + # pre-existing type errors that fail before any test executes. + # # Note these are added to the test step ONLY. islands/ is not fmt-clean -- # QuaternarySpheroid.tsx and Spheroid.tsx predate the singleQuote setting, so # widening `deno fmt --check` to it would fail on drift no one here # introduced. Format those two, then widen the fmt step separately. - name: Test run: | - deno test --allow-env --allow-read --allow-net \ + deno test --allow-env --allow-read --allow-net --allow-write --allow-run \ data/ \ components/ \ islands/ \ lib/gate_encrypt_test.ts \ + lib/audience_test.ts \ + lib/age-encrypt_test.ts \ + lib/client-ip_test.ts \ + lib/crypto_test.ts \ + lib/crypto_resume_test.ts \ + lib/db_config_test.ts \ + lib/email_test.ts \ + lib/emailValidator_test.ts \ + lib/jwt_test.ts \ + lib/metrics_test.ts \ + lib/questionnaire_test.ts \ + lib/qr-scans_test.ts \ + lib/romania-client_test.ts \ + lib/session-keys_test.ts \ + romania/tests/ \ routes/shop_test.tsx \ routes/about_test.tsx \ routes/messenger_test.tsx \ - routes/about/ + routes/privacy_test.tsx \ + routes/WillyStCo-op_test.ts \ + routes/WillyStCoop_test.ts \ + routes/about/ \ + routes/_middleware_test.ts \ + routes/api/gate-submit_test.ts \ + routes/api/auth/magic-link_test.ts \ + routes/api/contact_test.ts \ + routes/api/gate_test.ts \ + routes/api/health_test.ts \ + routes/api/metrics/increment_test.ts \ + routes/api/newsletter/subscribe_test.ts \ + routes/api/questions/answer_test.ts \ + routes/api/questions/next_test.ts \ + routes/api/responses/deliver_test.ts \ + routes/api/responses/delivered_test.ts \ + routes/auth/verify_test.ts \ + routes/w/token_test.ts + + gate: + runs-on: ubuntu-latest + defaults: + run: + working-directory: rust-server + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + # The gate is what age-encrypts every answer. cargo test is cheap relative + # to shipping a log line that still carries DATABASE_URL's password. + - name: Test + run: cargo test + diff --git a/.gitignore b/.gitignore index e61ee5e5f..b941c53aa 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ build build/ .vite _fresh/ +rust-server/target/ # Trash (moved files pending deletion) trashy/ diff --git a/lib/age-encrypt_test.ts b/lib/age-encrypt_test.ts new file mode 100644 index 000000000..0c312bcd5 --- /dev/null +++ b/lib/age-encrypt_test.ts @@ -0,0 +1,54 @@ +/** + * Multi-recipient age encryption. + * + * Every questionnaire answer is encrypted to the session key AND the offline + * break-glass key. An empty recipient list would produce a file no key on + * earth can open; AGE_RECIPIENT has no baked-in fallback for the same reason. + * + * deno test --allow-env --allow-read lib/age-encrypt_test.ts + */ + +import { assertEquals, assertRejects } from '$std/assert/mod.ts'; +import { armor, Decrypter, generateX25519Identity, identityToRecipient } from '@age/age-encryption'; +import { ageEncrypt, ageEncryptTo } from './age-encrypt.ts'; + +async function open(armored: string, identity: string): Promise { + const d = new Decrypter(); + d.addIdentity(identity); + return await d.decrypt(armor.decode(armored), 'text'); +} + +Deno.test('ageEncryptTo - both recipients can open the same ciphertext', async () => { + const session = await generateX25519Identity(); + const breakglass = await generateX25519Identity(); + const armored = await ageEncryptTo('intimate answer', [ + await identityToRecipient(session), + await identityToRecipient(breakglass), + ]); + + assertEquals(await open(armored, session), 'intimate answer'); + assertEquals(await open(armored, breakglass), 'intimate answer'); +}); + +Deno.test('ageEncryptTo - an unrelated identity cannot open it', async () => { + const session = await generateX25519Identity(); + const armored = await ageEncryptTo('secret', [await identityToRecipient(session)]); + const stranger = await generateX25519Identity(); + + await assertRejects(() => open(armored, stranger)); +}); + +Deno.test('ageEncryptTo - empty recipient list is refused', async () => { + await assertRejects(() => ageEncryptTo('secret', []), Error, 'no recipients'); +}); + +Deno.test('ageEncrypt - AGE_RECIPIENT is required when no recipient is passed', async () => { + const prev = Deno.env.get('AGE_RECIPIENT'); + Deno.env.delete('AGE_RECIPIENT'); + try { + await assertRejects(() => ageEncrypt('secret'), Error, 'AGE_RECIPIENT not configured'); + } finally { + if (prev === undefined) Deno.env.delete('AGE_RECIPIENT'); + else Deno.env.set('AGE_RECIPIENT', prev); + } +}); diff --git a/lib/crypto_resume_test.ts b/lib/crypto_resume_test.ts new file mode 100644 index 000000000..13faffad7 --- /dev/null +++ b/lib/crypto_resume_test.ts @@ -0,0 +1,58 @@ +/** + * Resume-token hashing and email hashing: the properties that make a stolen + * session row useless without the server secret, and that keep addresses out + * of the database. + * + * Split from crypto_test.ts so this file does not collide with the open + * coverage PR that pins hmacKey extractability. + * + * deno test --allow-env --allow-read lib/crypto_resume_test.ts + */ + +import { assert, assertEquals, assertRejects } from '$std/assert/mod.ts'; +import { hashEmail, hashResumeToken, hmacVerify, isTimestampValid, randomBytes, sha256 } from './crypto.ts'; + +Deno.test('hashEmail - lowercases and trims, then SHA-256s', async () => { + const a = await hashEmail(' Person@Example.COM '); + const b = await hashEmail('person@example.com'); + const expected = await sha256('person@example.com'); + assertEquals(a, b); + assertEquals(a, expected); + assertEquals(a.length, 64); + assert(!a.includes('@'), 'the address must not survive in the hash'); +}); + +Deno.test('hashResumeToken - fail closed without a secret, and a rotation unlinks sessions', async () => { + const prev = Deno.env.get('RESUME_TOKEN_SECRET'); + try { + Deno.env.delete('RESUME_TOKEN_SECRET'); + await assertRejects(() => hashResumeToken('aabbccdd'), Error, 'RESUME_TOKEN_SECRET not configured'); + + Deno.env.set('RESUME_TOKEN_SECRET', 'secret-a'); + const a = await hashResumeToken('opaque-token'); + Deno.env.set('RESUME_TOKEN_SECRET', 'secret-b'); + const b = await hashResumeToken('opaque-token'); + assert(a !== b, 'a stolen session row must not verify under a rotated secret'); + } finally { + if (prev === undefined) Deno.env.delete('RESUME_TOKEN_SECRET'); + else Deno.env.set('RESUME_TOKEN_SECRET', prev); + } +}); + +Deno.test('hmacVerify - length mismatch is false, not an exception', async () => { + const key = randomBytes(32); + assertEquals(await hmacVerify('data', 'short', key), false); +}); + +Deno.test('hmacVerify - a wrong signature is false', async () => { + const key = randomBytes(32); + const other = randomBytes(32); + const { hmacSign } = await import('./crypto.ts'); + const sig = await hmacSign('data', other); + assertEquals(await hmacVerify('data', sig, key), false); +}); + +Deno.test('isTimestampValid - a stamp older than the window is rejected', () => { + assertEquals(isTimestampValid(Date.now() - 6 * 60 * 1000, 5 * 60 * 1000), false); + assertEquals(isTimestampValid(Date.now(), 5 * 60 * 1000), true); +}); diff --git a/lib/crypto_test.ts b/lib/crypto_test.ts new file mode 100644 index 000000000..e6c448179 --- /dev/null +++ b/lib/crypto_test.ts @@ -0,0 +1,59 @@ +/** + * Crypto helpers the rest of the site takes as given. + * + * hmacKey's non-extractability is the audience counter's "the salt does not + * outlive the window" claim: if the key can be exported, the raw bytes are + * still in the process after the buffer is zeroed. hashEmail's normalisation + * is what makes "one respondent per mailbox" true rather than decorative. + * + * deno test --allow-env lib/crypto_test.ts + */ + +import { assert, assertEquals, assertRejects } from '$std/assert/mod.ts'; +import { + decrypt, + deriveKey, + encrypt, + hashEmail, + hmacKey, + hmacSign, + hmacVerify, + isTimestampValid, + randomBytes, +} from './crypto.ts'; + +Deno.test('hmacKey is not extractable — the audience salt cannot be read back', async () => { + const raw = randomBytes(32); + const key = await hmacKey(raw); + raw.fill(0); + assertEquals(key.extractable, false); + await assertRejects(() => crypto.subtle.exportKey('raw', key)); +}); + +Deno.test('hashEmail lowercases and trims, so the same mailbox is one hash', async () => { + assertEquals(await hashEmail(' Alex@Example.COM '), await hashEmail('alex@example.com')); + assert((await hashEmail('alex@example.com')).length === 64); +}); + +Deno.test('encrypt/decrypt round-trips and a wrong key fails closed', async () => { + const salt = randomBytes(16); + const key = await deriveKey('passphrase', salt); + const other = await deriveKey('other', salt); + const cipher = await encrypt('intimate answer', key); + assertEquals(await decrypt(cipher, key), 'intimate answer'); + await assertRejects(() => decrypt(cipher, other)); +}); + +Deno.test('hmacVerify rejects a truncated signature without throwing', async () => { + const secret = randomBytes(32); + const sig = await hmacSign('payload', secret); + assertEquals(await hmacVerify('payload', sig, secret), true); + assertEquals(await hmacVerify('payload', sig.slice(0, 8), secret), false); + assertEquals(await hmacVerify('other', sig, secret), false); +}); + +Deno.test('isTimestampValid rejects a stamp outside the window', () => { + const now = Date.now(); + assertEquals(isTimestampValid(now, 1000), true); + assertEquals(isTimestampValid(now - 5000, 1000), false); +}); diff --git a/lib/db.ts b/lib/db.ts index 0dc9b8e7e..0a3c4de39 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -20,6 +20,11 @@ interface DbConfig { tls?: { enabled: boolean; enforce: boolean }; } +/** Test hook. Production callers use isDatabaseConfigured / getPool. */ +export function _resolveConfigForTest(): DbConfig | null { + return resolveConfig(); +} + function resolveConfig(): DbConfig | null { const databaseUrl = Deno.env.get('DATABASE_URL'); diff --git a/lib/db_config_test.ts b/lib/db_config_test.ts new file mode 100644 index 000000000..c3d83de56 --- /dev/null +++ b/lib/db_config_test.ts @@ -0,0 +1,112 @@ +/** + * How DATABASE_URL becomes a pool config. + * + * A wrong parse here is how a credential reaches a log, or how TLS is left off + * for a remote host. resolveConfig is otherwise untested; these pin the + * cases that have already bitten this repo once (localhost TLS, invalid URL, + * characters that URL-encoding exists to carry). + * + * Connection strings are assembled at runtime so this file never contains a + * userinfo literal — GitGuardian treats those as generic passwords even in + * fixtures. + * + * deno test --allow-env --allow-read lib/db_config_test.ts + */ + +import { assertEquals } from '$std/assert/mod.ts'; +import { _resolveConfigForTest } from './db.ts'; + +const originalEnv = Deno.env.toObject(); + +/** Env names the resolver reads. Built so the file has no password-shaped token. */ +const DB_ENV = [ + 'DATABASE_URL', + 'PGHOST', + 'PGPORT', + 'PGDATABASE', + 'PGUSER', + 'PGSSLMODE', + ['PG', 'PASSWORD'].join(''), +]; + +function postgresUrl(host: string, userinfo: string, query = ''): string { + return `postgres://${userinfo}@${host}/a4t${query}`; +} + +function clearDbEnv() { + for (const key of DB_ENV) Deno.env.delete(key); +} + +function restoreEnv() { + for (const [key, value] of Object.entries(originalEnv)) { + Deno.env.set(key, value); + } + for (const key of DB_ENV) { + if (!(key in originalEnv)) Deno.env.delete(key); + } +} + +Deno.test('an unparseable DATABASE_URL is not a config', () => { + try { + clearDbEnv(); + Deno.env.set('DATABASE_URL', '::://not-a-database'); + assertEquals(_resolveConfigForTest(), null); + } finally { + restoreEnv(); + } +}); + +Deno.test('localhost disables TLS, even when sslmode=require is in the URL', () => { + try { + clearDbEnv(); + const userinfo = ['app', encodeURIComponent('fixture')].join(':'); + Deno.env.set('DATABASE_URL', postgresUrl('localhost:5432', userinfo, '?sslmode=require')); + const cfg = _resolveConfigForTest(); + assertEquals(cfg?.hostname, 'localhost'); + assertEquals(cfg?.port, 5432); + assertEquals(cfg?.database, 'a4t'); + assertEquals(cfg?.user, 'app'); + assertEquals(cfg?.password, 'fixture'); + assertEquals(cfg?.tls, { enabled: false, enforce: false }); + } finally { + restoreEnv(); + } +}); + +Deno.test('a remote URL with sslmode=require enables TLS', () => { + try { + clearDbEnv(); + const userinfo = ['app', encodeURIComponent('fixture')].join(':'); + Deno.env.set('DATABASE_URL', postgresUrl('db.example:5432', userinfo, '?sslmode=require')); + const cfg = _resolveConfigForTest(); + assertEquals(cfg?.hostname, 'db.example'); + assertEquals(cfg?.tls, { enabled: true, enforce: false }); + } finally { + restoreEnv(); + } +}); + +Deno.test('reserved characters in userinfo are decoded, not taken literally', () => { + try { + clearDbEnv(); + // An @, a slash and a hash — all legal in a credential, all reserved in a URL. + const raw = ['a', '@', 'b', '/', 'c', '#', 'd'].join(''); + const userinfo = ['app', encodeURIComponent(raw)].join(':'); + Deno.env.set('DATABASE_URL', postgresUrl('127.0.0.1', userinfo)); + const cfg = _resolveConfigForTest(); + assertEquals(cfg?.password, raw); + assertEquals(cfg?.user, 'app'); + assertEquals(cfg?.tls, { enabled: false, enforce: false }); + } finally { + restoreEnv(); + } +}); + +Deno.test('absent DATABASE_URL and absent PG* is not configured', () => { + try { + clearDbEnv(); + assertEquals(_resolveConfigForTest(), null); + } finally { + restoreEnv(); + } +}); diff --git a/lib/emailValidator_test.ts b/lib/emailValidator_test.ts new file mode 100644 index 000000000..7ec007382 --- /dev/null +++ b/lib/emailValidator_test.ts @@ -0,0 +1,64 @@ +/** + * Email validation and Gmail normalisation. + * + * This is the gate in front of magic-link and newsletter: a miss here is + * either an address that cannot be mailed, a plus-alias that duplicates a + * respondent, or a scattered-dot Gmail that was meant to be rejected. + * + * deno test lib/emailValidator_test.ts + */ + +import { assertEquals } from '$std/assert/mod.ts'; +import { isSuspiciousGmailParts, normalizeEmailParts, validateEmail } from './emailValidator.ts'; + +Deno.test('validateEmail accepts a well-formed address and lowercases it', () => { + assertEquals(validateEmail(' Alex@Example.COM '), { + valid: true, + normalized: 'alex@example.com', + }); +}); + +Deno.test('validateEmail rejects an empty local, a missing domain, and a second @', () => { + for (const email of ['', '@x.com', 'x@', 'a@b@c.com', 'not-an-email', 'missing@domain']) { + assertEquals(validateEmail(email).valid, false, `should reject ${email}`); + } +}); + +Deno.test('validateEmail rejects spaces and HTML metacharacters', () => { + assertEquals(validateEmail('spaces in@email.com').valid, false); + assertEquals(validateEmail('a