Skip to content

Endurecimiento de seguridad del starter (S-1: 22 tickets)#17

Merged
dherrero merged 24 commits into
mainfrom
feat/security-hardening-s1
Jun 10, 2026
Merged

Endurecimiento de seguridad del starter (S-1: 22 tickets)#17
dherrero merged 24 commits into
mainfrom
feat/security-hardening-s1

Conversation

@dherrero

@dherrero dherrero commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Endurecimiento de seguridad del starter (spec S-1)

Implementa los 22 tickets del spec S-1, un commit por ticket. Decisiones tomadas con criterio de seguridad y encaje con la arquitectura del starter (gateway EdDSA + API privada + SPA Angular).

Críticos / Altos

  • T-1 [CRÍTICO] Elimina el ADMIN sembrado con bcrypt('123456') del esquema; seed dev-only fail-safe-off (DEV_SEED_ADMIN, hash externo), POSTGRESDB_PASSWORD obligatorio, credenciales rotadas en los READMEs.
  • T-2 [ALTO] Login/getUser filtran deleted:false → las cuentas soft-deleted ya no autentican.
  • T-3 [ALTO] Allow-list de campos escribibles (fields de Sequelize) contra mass-assignment / escalada de privilegios.
  • T-4 [ALTO] Capa de validación Zod en @dto + middleware validate() (.strict()), patrón para toda entidad.
  • T-5 [ALTO] Rotación re-lee permisos de la fuente autoritativa (API) y rechaza usuarios borrados; remember acotado (JWT_REFRESH_REMEMBER_DAYS, 30d) y booleano estricto.

Medios

  • T-6 Rate-limiting (login por IP+email, anti credential-stuffing).
  • T-7 npm audit bloqueante (prod deps) + grafo de jobs notify arreglado.
  • T-8 release.yml por workflow_dispatch, persist-credentials:false, token sólo en el push, --ignore-scripts.
  • T-9 Postgres dev a loopback (127.0.0.1).
  • T-10 Guards cableados a rutas + ruta unauthorized + ejemplo profile.
  • T-11 Anti open-redirect (sanitizeRedirect, same-origin).
  • T-12 El interceptor sólo adjunta token/credenciales a orígenes de confianza.
  • T-13 requestId interno generado en servidor + strip de cabeceras de confianza.
  • T-14 Cabeceras de seguridad: helmet (gateway/API) + CSP/HSTS/X-Frame-Options en nginx.
  • T-15 /v1/health/{db,detailed} tras auth interna y sin fingerprinting (node/pid/DB).

Bajos

  • T-16 Coste bcrypt como número, mínimo 12.
  • T-17 put respeta deleted:false + paginación validada/acotada.
  • T-18 Higiene de logs (sin bodies/PII/errores completos; NODE_ENV para SQL log).
  • T-19 Imagen nginx pineada + clockTolerance en verificación interna.
  • T-20 CORS fail-closed, logout por familyId, iss/aud en tokens, body limit, 401 genérico en login.
  • T-21 Bug de truthiness en tokenDecoded + postura CSRF documentada.
  • T-22 Suposición de replay del token interno documentada en docs/SECURITY.md.

Validación

  • npm run lint limpio
  • 175 tests verdes (105 root + 70 front), con tests nuevos para los cambios de seguridad
  • npm audit --omit=dev --audit-level=high sin issues en producción
  • 📄 AGENTS.md por capa actualizados (db, api, gateway, front, rest-dto)

Pasos manuales tras el merge

  • npm install (nuevas deps: zod, helmet, express-rate-limit, supertest dev).
  • Crear .env desde .env.example (ahora POSTGRESDB_PASSWORD es obligatorio).
  • Para un admin local: bash scripts/gen-admin-hash.sh '<pass>' + DEV_SEED_ADMIN=true.
  • Recrear la DB dev (npm run dev:db:clean && npm run dev:db) para aplicar el esquema sin el seed antiguo.

🤖 Generated with Claude Code

dherrero and others added 24 commits June 9, 2026 23:04
… behind flag

[CRITICAL] T-1: the schema seeded [email protected] with a public bcrypt('123456')
ADMIN via docker-entrypoint-initdb.d, and the password was documented in the
READMEs. POSTGRESDB_PASSWORD also silently defaulted to 'password'.

- Drop the hardcoded ADMIN INSERT/setval from db/10.user.sql (schema seeds no users).
- Add fail-safe-OFF dev seed db/zz-dev-seed.sh: no-op unless DEV_SEED_ADMIN=true
  with an explicit BOOTSTRAP_ADMIN_EMAIL + externally-generated bcrypt hash.
- Add scripts/gen-admin-hash.sh helper to produce the hash (cost 12).
- Make POSTGRESDB_PASSWORD mandatory in docker-compose.db.yml (no :-password default).
- Rotate documented credentials in README.md / docs/README_eng.md to seed instructions.
- Document the no-secrets-in-schema rule in db/AGENTS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[ALTO] T-2: validateCredentials/getUser queried User without deleted:false, so a
soft-deleted account (offboarded admin, compromised user) could still log in and
receive its original permissions.

- validateCredentials: where { email, deleted: false }.
- getUser: switch findByPk -> findOne with { id, deleted: false } so the filter
  is actually enforced (findByPk ignores extra where on the PK).
- Update/extend auth.service.spec.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[ALTO] T-3: AbstractCrudService passed req.body verbatim to model.create/update,
letting a caller over-post permissions, deleted, deletedAt or id (privilege
escalation / tampering). This is the base CRUD for every future entity, so the
pattern would propagate.

- AbstractCrudService: optional writableFields allow-list, applied as Sequelize's
  { fields } option on post/put so unlisted keys are dropped.
- UserCrudService: declare USER_WRITABLE_FIELDS (email,name,lastName,permissions,
  password) — excludes id/deleted/deletedAt/audit columns even for admins.
- Update specs to assert the fields allow-list is enforced.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[ALTO] T-4: the API had no input validation — req.body/query/params flowed
untyped into Sequelize, enabling mass-assignment, NaN pagination and
inconsistent errors. This establishes the validation pattern for every entity.

- libs/rest-dto: add Zod schemas (userCreateSchema, userUpdateSchema,
  paginationQuerySchema, idParamSchema) as the single source of truth; input
  schemas are .strict() so unknown keys are rejected.
- apps/api: add validate(schema, source) middleware (Express 5-safe: body/params
  replaced in place, query exposed on res.locals.query); wire body validation
  onto user POST/PUT.
- Tests for the middleware; document the layer in api & rest-dto AGENTS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[ALTO] T-5: refresh rotation copied permissions from the OLD refresh token, and
remember=true minted a 365-day token, so a downgraded/revoked user kept elevated
access for up to a year. remember was also read truthy from req.body.

- API /internal/refresh/rotate re-reads the user (authService.getUser, which
  filters deleted:false) and returns current email+permissions; revokes the
  family and 401s if the user is gone/deactivated.
- Gateway uses the API-returned claims on rotation, not the stale token claims.
- Bound remember lifetime via JWT_REFRESH_REMEMBER_DAYS (default 30); JWT expiry
  and cookie maxAge share one source (token.service.rememberRefresh*).
- Coerce remember to a strict boolean (=== true) in the login controller.
- Update gateway specs + AGENTS.md; add env to .env.example/compose.yaml.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[MEDIO] T-6: /login and the auth surface had no throttling/lockout, enabling
credential stuffing and password brute force.

- Add express-rate-limit limiters: loginRateLimiter keyed by IP+email counting
  only failed attempts (skipSuccessfulRequests), and a coarse per-IP
  authRateLimiter over the whole /v1/auth router.
- Configurable via LOGIN_RATE_*/AUTH_RATE_* env (sane defaults); relies on the
  existing trust proxy:1 to see the real client IP behind Nginx.
- Add supertest-based tests; document env in .env.example.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[MEDIO] T-7: the security job ran 'npm audit ... || true' with
continue-on-error, so vulnerable deps never blocked a merge; and notify needs
[lint,test,build,security,docker] referenced build/docker which are commented
out, making the graph invalid.

- Gate on production deps: npm audit --omit=dev --audit-level=high (blocking),
  full report still generated+uploaded for visibility. Dev/build-tool advisories
  churn and aren't shipped, so they're reported, not gated.
- Apply non-breaking npm audit fix: clears the production axios high advisory
  (remaining prod issues are moderate, below the gate).
- Fix notify needs -> [lint, test, security].

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[MEDIO] T-8: release.yml ran on every push to main with contents:write and
pushed back to main using a token left in the local git config
(persist-credentials default true). Any pre-push lifecycle script/dependency
could reuse that credential to push to main / cut releases.

- Trigger via workflow_dispatch with a bump choice (deliberate, maintainer-gated)
  instead of automatic push-to-main.
- Top-level permissions: contents:read; elevate to write only in the job.
- Checkout with persist-credentials:false; inject GITHUB_TOKEN only at the push
  step via an x-access-token remote URL.
- npm version --ignore-scripts so no project/dep scripts run on the privileged
  runner before the push.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[MEDIO] T-9: docker-compose.db.yml published 5432 on 0.0.0.0, exposing the dev
database to the whole network. Combined with the (now-removed, see T-1) default
password it was a direct DB compromise on an untrusted network.

- Publish on 127.0.0.1:${POSTGRESDB_PORT:-5432}:5432 so only the local host can
  reach it. Password was already made mandatory in T-1.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[MEDIO] T-10: app.routes.ts declared no canActivate, so access control relied
solely on @if(auth.isLoggedIn()) in templates, and the guards redirected to a
non-existent 'unauthorized' route (404 inside the router).

- Add a real 'unauthorized' route + page (the guards' redirect target).
- Add a guarded example 'profile' route (canActivateFn) demonstrating the
  protected-route pattern; link it from the logged-in home card.
- Add a '**' wildcard redirect to the landing page.
- i18n (en/es/ca) for the new pages; document the guard convention in
  apps/front/AGENTS.md (guards are UX only — backend enforces authz).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[MEDIO] T-11: login.component took redirectUrl from navigation state and passed
it to navigateByUrl without validation, allowing redirects to arbitrary internal
routes and mishandled '//evil.com'-style values.

- Add sanitizeRedirect() util: accepts only same-origin relative paths (single
  leading '/', no '//', no backslashes, no scheme); returns '' otherwise.
- Use it when reading the redirect from navigation state.
- Unit tests covering the open-redirect payloads.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[MEDIO] T-12: the auth interceptor added Authorization + withCredentials to
EVERY request regardless of target. The moment the app calls a third-party
absolute URL (CDN, analytics, avatar), the in-memory access token would leak in
the Authorization header.

- Attach token/withCredentials only when the request resolves to the app origin
  or the explicitly-configured API origin (AuthConfig.idpServer).
- Don't capture an Authorization response header from an untrusted origin, and
  don't trigger logout on a third-party 401.
- Tests for the cross-origin cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…trust headers

[MEDIO] T-13: the gateway read the client's x-request-id and used it verbatim as
the requestId embedded in the signed internal JWT and forwarded to the API,
enabling correlation forgery/collision and log poisoning. Inbound internal
headers were also not stripped defensively.

- Always mint requestId with randomUUID() (never from the client header).
- Strip inbound x-internal-auth / x-request-id at the start of the proxy, and
  removeHeader them on proxyReq before setting the gateway-minted values.
- (API already derives requestId from the verified token claims, not the
  header — defense in depth already in place there.)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[MEDIO] T-14: neither the gateway nor the API used helmet, and the front's nginx
set no CSP/X-Frame-Options/nosniff. Since the access token is JS-visible by
design, a strong CSP is a key XSS mitigation.

- Gateway & API: helmet() (HSTS, nosniff, X-Frame-Options: DENY, …); CSP off
  there since they return JSON only — the document CSP belongs to the front.
- Front nginx (nginx/default.conf): CSP (script-src 'self', object-src 'none',
  frame-ancestors 'none', style-src 'unsafe-inline' for Angular styles),
  X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy, HSTS;
  re-declared in the /assets and /index.html locations (nginx add_header
  inheritance caveat).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[MEDIO] T-15: /v1/health/detailed and /v1/health/db exposed Node version,
platform, pid, memory/cpu and DB host/port/name with no auth, aiding CVE
targeting and lateral movement if the network were misconfigured.

- Keep /v1/health (liveness) public but minimal (health + timestamp).
- Gate /db and /detailed behind requireInternalAuth (gateway-signed token).
- Strip fingerprinting: drop nodeVersion/pid/platform and DB host/port/name/
  dialect; expose only connection booleans/counters + operational metrics.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[BAJO] T-16: hashPassword passed process.env.HASH_SALT_ROUNDS (a string) to
bcrypt.hash, which treats a string as a pre-generated salt; cost was also only
10 with no floor.

- parseInt the env and enforce a minimum cost of 12 (modern baseline).
- Default HASH_SALT_ROUNDS to 12 in .env.example and compose.yaml.
- Tests for numeric coercion and the minimum-cost clamp.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[BAJO] T-17: after a put the base controller re-read with getById({id}) without
deleted:false, allowing read/edit of soft-deleted rows; and page/limit came
straight from req.query (NaN/negative offset, unbounded limit -> memory/DoS).

- put: pre-check the row with deleted:false (404 if gone), and read back with
  deleted:false so soft-deleted records stay invisible.
- getAllPaged: parse+clamp via paginationQuerySchema (page>=1, 1<=limit<=100),
  falling back to defaults on invalid input; reads res.locals.query when a
  validate('query') middleware ran.
- Add abstract-crud.controller spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[BAJO] T-18:
- dbLoggingMiddleware logged the response body + client IP + User-Agent on any
  5xx (PII / internals leakage) -> log only method/path/status/timestamp.
- sequelizeErrorMiddleware logged the full error object (query fragments/values)
  -> log only { name, code }.
- Remove orphan console.log(error) in AbstractCrudController.put.
- pg.connector gated SQL logging on NODE_PRODUCTION only; now fail-safe on
  NODE_ENV==='production' || NODE_PRODUCTION==='true' so SQL+params are never
  logged in prod if one var is unset.
- Update db-error middleware specs.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[BAJO] T-19:
- apps/front/Dockerfile used FROM nginx (=> nginx:latest): non-reproducible
  builds. Pin to nginx:1.27 (Debian, keeps the groupadd/useradd block working;
  -alpine would break it).
- verifyInternalAuth had no clockTolerance while the internal token TTL is 60s,
  so clock drift between containers could cause intermittent 401s on legit
  gateway->API calls. Add clockTolerance: '5s'.
- Tests: expired-beyond-tolerance still rejected; small drift now tolerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[BAJO] T-20, several:
- CORS fail-closed: drop the dead '?? true' fallback; empty CORS_ORIGIN allows
  no cross-origin (logged as misconfig) instead of risking reflect-any.
- Logout revokes the whole refresh family: embed familyId in the refresh JWT and
  revoke by familyId (fallback to jti), so logout ends the session everywhere.
- Public tokens now carry issuer/audience and are verified (issuer+audience+
  clockTolerance), so a token minted elsewhere can't be replayed.
- express.json with an explicit 100kb limit, applied only to /api/v1/auth; proxy
  traffic is streamed, never parsed here.
- Login always returns a generic 401 instead of forwarding the upstream message
  (prevents account enumeration).
- Tests for iss/aud binding; keep typ-enforcement test meaningful.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[BAJO] T-21:
- auth.service.tokenDecoded tested the signal reference (this.token, always
  truthy) instead of its value, so an empty token led to atob/JSON.parse on
  undefined and threw. Read this.token(), guard empty, and try/catch -> {}.
- Document the CSRF posture in apps/front/AGENTS.md: state-changing requests
  require the Authorization header (non-cookie proof) + SameSite=strict refresh
  cookie in prod; no cookie XSRF token, so no withXsrfConfiguration.
- Tests for empty and malformed tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
[BAJO] T-22: the internal EdDSA token carries a requestId but the verifier does
not enforce jti/nonce uniqueness, so within its 60s TTL a captured
X-Internal-Auth could be replayed. This is acceptable for the starter given the
network design — document the assumption explicitly in docs/SECURITY.md and
describe how to harden (short-lived jti cache) if a less-trusted service ever
crosses the internal boundary.

Also refresh SECURITY.md for prior fixes: bounded remember lifetime
(JWT_REFRESH_REMEMBER_DAYS), iss/aud on client tokens, authoritative
permission re-read on rotation, server-minted requestId + header stripping, and
logout-by-family.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@dherrero dherrero merged commit c062857 into main Jun 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant