ts-auth gains a Postgres service and migrations. The security regressions
this branch adds - wrong password, pass-the-hash, wrong TOTP code, lockout,
single-use recovery codes - are all decided in the database, so without one
they skip silently and CI would go green on an unauthenticated server.
webmail-build only ran "npm run build", so i18n.test.ts never executed
anywhere. It now lints and tests too, and the package gained the lint
script it was missing.
*.tsbuildinfo is ignored: tsc --noEmit leaves it behind and it is a local
build cache, not source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three separate gaps, all of which made the F4 interface non-functional or
unprotected:
The admin console rendered for anyone. There was no middleware and no
guard in the layout, so /admin/dashboard returned 200 unauthenticated -
only its data calls failed. middleware.ts now redirects both surfaces to
their login when the session cookie is missing, expired, carries the wrong
audience, or is a pre-MFA challenge token. It reads the claims without
verifying the signature on purpose: the signing key belongs to the auth
service and must not reach the browser bundle. That is sound because this
is not the boundary - every /api/v1 request is verified for real upstream.
Nothing served /api/v1/*. next.config.ts had no rewrites and there were no
route handlers, so every call the pages made 404'd against the Next
server, while AUTH_SERVICE_URL sat unused. A catch-all route now proxies
to the auth service, forwarding the client address and preserving multiple
Set-Cookie headers, which logout needs to clear both surfaces. Session
cookies also move to Path=/, since a cookie pinned to /user or /admin was
never sent with the /api/v1 requests that needed it.
i18n existed only as files. The three bundles had perfect key parity and
real translations, but no library, no imports and no switcher: the UI was
hardcoded English and i18n.test.ts compared keys while the feature was
absent. There is now a provider, a hook, a language switcher, and
server-side Accept-Language negotiation that honours q-values and matches
a bare "pt" to pt-BR. Logins, both layouts and the settings screen consume
it; the admin pages still have English titles.
The settings screen invented app passwords in the browser with Math.random
and never stored them. It now calls the API, which returns the password
once and keeps only its hash.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The F4 auth service issued a valid session for any email and any password,
and accepted any non-empty string as a second factor. Reproduced against
the router before touching it: an invented password plus the code "000000"
returned a SUPER_ADMIN token that opened /api/v1/admin/dashboard.
Passwords. Hashing moves from PBKDF2 to Argon2id via hash-wasm, which is
what PLAN.md section 9 and the Go service already use - the previous
scheme meant a password set through the mail stack could never verify
here. Interoperability was checked in both directions before adopting the
library: a hash written by alexedwards/argon2id verifies in Node and one
written here verifies in Go. hash-wasm is pure WASM, so the multi-arch
Alpine images need no native build.
verifyPassword no longer falls back to comparing the password against the
stored hash. That fallback turned every stored hash into a working
credential: anyone with a database dump could authenticate by sending the
hash back. Anything that is not an argon2id PHC string is now refused.
Logins are decided against the database, with the failure counter and the
temporary lockout of PLAN.md section 5.2. An unknown address and a wrong
password give the same answer, and the unknown case still pays for a hash
so response time does not enumerate valid addresses.
Second factor. The TOTP secret is generated server-side, stored encrypted
and never accepted back from the client - the confirm step used to take
whichever secret the caller sent, so anyone could confirm with their own.
Recovery codes are Argon2id, single-use, and consumed under a guard so two
concurrent requests cannot spend the same one. App passwords are issued
and revoked through real endpoints instead of browser state.
Sessions gain a "purpose" claim. Without it the challenge token - handed
out after the password but before the second factor - satisfied every
check a real session did, which skipped MFA entirely.
The dev seed carried a hand-written password_hash whose digest was 24
bytes instead of 32, so it matched no password at all. Nothing noticed
while passwords went unverified; it is a real hash now.
Verified with 30 tests, 13 of them against a real Postgres: wrong password,
unknown account, pass-the-hash, wrong TOTP code, challenge token replay,
lockout, single-use recovery codes, and the surface isolation that F4's
acceptance criteria require.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Concurrency. release.yml calls ci.yml, so a push to main started the
called workflow and the standalone one, both landing in the concurrency
group ci-<ref>. One cancelled the other and every merge left a cancelled
run behind. main is now excluded from the push trigger: release.yml is
already the entry point there, and pull_request still covers changes
before they land.
Node 20 deprecation (23 warnings). Every action was pinned to a major
that still declares node20. Each replacement was checked by reading the
action.yml at the new tag and confirming "using: node24", and the inputs
in use were confirmed to still exist there:
checkout v4->v7, setup-node v4->v7, setup-go v5->v7,
upload-artifact v4->v7, download-artifact v4->v7,
setup-buildx-action v3->v4, build-push-action v6->v7,
login-action v3->v4, metadata-action v5->v6
download-artifact has a v8, but v7 is also node24 and matches the upload
side, so the pair stays on one major. trivy-action is a composite action
and has no Node runtime to deprecate.
Go cache (1 warning). setup-go looked for go.sum at the repository root
and skipped its cache: defaults.run.working-directory applies to run
steps, not to actions. cache-dependency-path now points at
services/protocols/go.sum.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The E2E suite has never run in CI. repoRoot() walked up looking for
PLAN.md, but .gitignore excludes PLAN.md on purpose ("Internal planning
docs - never published with the code"), so it is absent from every fresh
checkout and the helper always failed. All twelve E2E tests died on it,
in this run and identically in #18 before any of these changes.
The marker is now the migrations directory, which is committed and is
what every caller of repoRoot is ultimately looking for.
Reproduced by exporting the tree with git archive - which yields exactly
what actions/checkout puts on the runner - and running the suite there:
it fails with the old helper and passes with the new one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI gains a security job (Trivy fs: vuln, secret and misconfig) and an
image job that builds each of the four services and scans the result. The
secret scanner earns its place here: a DKIM key or Cloudflare token
committed by accident is a full compromise of every domain served.
release.yml publishes on a merge into main and on v* tags. Four services
on two architectures is eight build jobs pushing by digest, then four
publish jobs joining each pair into one manifest list, tagged latest, sha
and semver. github.repository is lowercased first because GHCR rejects an
uppercase path and this repository is lucasrguerra/LambdaMail.
Both scanners failed on first run, so this commit also fixes what they
found rather than shipping a workflow that is red on arrival:
- All four images ran as root (DS-0002), which section 17 risk R5
explicitly argues against. They now run unprivileged; protocols keeps
its eight ports below 1024 through cap_net_bind_service on the binary
rather than through being root.
- A HIGH advisory in postcss, reached only through Next. "npm audit fix
--force" would have resolved it by downgrading Next from 15 to 9, so
it is pinned with an override instead.
- The Node images copied the build stage's node_modules wholesale,
shipping typescript, eslint and vitest into production, and the base
image's own bundled npm accounted for the rest of the CVEs. Runtime
dependencies now come from npm ci --omit=dev and npm is removed from
the final layer.
- compose-up could never have passed: the proxy network is external
because Coolify owns it on the deploy host, and nothing creates it in
CI, so compose refused to start the stack at all.
Verified locally: actionlint clean, all ten action refs confirmed to
exist, Trivy fs and all four image scans exit 0, the full stack reaches
Healthy on every container, and the containers run as uid 10001/1000 with
all eight privileged ports bound and SMTP answering on 25. The arm64
build itself is unverified - this machine has no buildx - and will first
be exercised by release.yml.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"docker compose up" could not work, which means the F0 acceptance
criterion was not being met:
- ghcr.io/rspamd/rspamd:3.9 returns "denied" and clamav/clamav:1.3 does
not exist. Both were verified against the registries; the working
references are rspamd/rspamd:3.9 and clamav/clamav:1.4.
- Rspamd received RSPAMD_REDIS_SERVERS and RSPAMD_REDIS_PASSWORD as
environment variables it never reads. Confirmed in the container that
it does not expand ${VAR} in UCL: the password reached Redis as that
literal string. Greylisting and ratelimit keep their state in Redis,
so both were silently doing nothing while appearing configured. The
credential is now rendered at startup into override.d, a tmpfs, so it
touches neither the image nor a volume.
- The ratelimit module ships enabled with no rates defined, which
accepts everything. ratelimit.conf defines the per-IP, per-user and
per-recipient buckets of section 5.2.
Verified by bringing redis and rspamd up: the real password resolves, the
rates load, and a probe message returns "Action: greylist" with the state
written to Redis.
Services also gained an image: alongside build:, so the deploy host pulls
instead of compiling while "docker compose build" still works locally.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every fix here is a case where the code and the plan disagreed, and the
code was wrong.
Certificates (section 8.2, 8.4). The parser documented that it skipped
expired entries but never checked, so an expired certificate would be
served until every handshake failed. LastReload() only advanced when
acme.json changed - and it does not change for sixty days - which made
the timestamp risk R2 monitors indistinguishable from a watcher gone
blind. Expiry is now checked on every pass, LastReload records the check
and LastChange the swap, and the <7d/<24h alerts of section 8.4 exist.
ARC (section 5). buildArcSealer resolved the DKIM key at startup, before
runDnsReconciler had provisioned one. On a first boot ARC was disabled
until someone restarted the process, which the log even admitted while
offering no way for it to start. A LazySealer binds on first use instead.
Health (section 8.4). There was no degraded state at all. /health now
reports one - still 200, because restarting cannot fix a certificate
Traefik never issued.
Bounces. DSNs were sent From postmaster@lambdamail.local, a domain that
does not resolve, so the notification was itself undeliverable. They now
carry the configured mail host, a Message-ID, and Auto-Submitted so a
vacation responder cannot loop against them.
Fan-out atomicity (section 6.1). Persist ran one transaction per
recipient, so a failure halfway through an alias fan-out left earlier
recipients with a copy while the sender was told to retry - and the retry
delivered to them twice. PersistAll commits the whole delivery or none of
it. Verified against Postgres by reverting the repository to the old
behaviour and confirming the new test fails on it.
SMTP replies. The use case encoded replies in error text ("550 5.7.1 ...")
which the adapter re-parsed by prefix, so rewording a message could turn a
permanent refusal into a retry. SmtpRejection carries the decision and the
adapter renders it.
Repository tests. These only run with TEST_DATABASE_URL, which CI does
set - so the job was red: copy and expunge seeded a 68-character digest
into a CHAR(64) column, and three others used fixed digests that collide
with the UNIQUE index on any re-run. A shared testBlobDigest() helper
fixes both; the suite now passes twice in a row against one database.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>