Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b92a1e2
fix(security): remove seeded ADMIN with known password; gate dev seed…
dherrero Jun 9, 2026
e0765c4
fix(security): reject soft-deleted users at authentication
dherrero Jun 9, 2026
a788a0e
fix(security): allow-list writable fields to stop mass-assignment
dherrero Jun 9, 2026
efaafbb
feat(security): add Zod input-validation layer at the API edge
dherrero Jun 9, 2026
b8e5488
fix(security): re-read permissions on refresh rotation; bound remember
dherrero Jun 9, 2026
453d382
feat(security): rate-limit gateway auth endpoints
dherrero Jun 9, 2026
19055a3
ci(security): make audit gate real and fix invalid job graph
dherrero Jun 9, 2026
b5af66d
ci(security): harden release workflow against supply-chain abuse
dherrero Jun 9, 2026
58bf213
fix(security): bind dev Postgres to loopback only
dherrero Jun 9, 2026
11e14ae
feat(security): wire auth guards to routes + add unauthorized page
dherrero Jun 9, 2026
0caab96
fix(security): validate post-login redirect against open redirect
dherrero Jun 9, 2026
62e9970
fix(security): only attach token/credentials to trusted origins
dherrero Jun 9, 2026
9c4c8bd
fix(security): generate internal requestId server-side; strip client …
dherrero Jun 9, 2026
91e5212
feat(security): add security headers (helmet + nginx CSP)
dherrero Jun 9, 2026
acc9b65
fix(security): lock down API health detail endpoints
dherrero Jun 9, 2026
933c299
fix(security): pass bcrypt cost as number; enforce minimum 12
dherrero Jun 9, 2026
56129e5
fix(security): filter deleted on put read-back + clamp pagination
dherrero Jun 9, 2026
952e356
fix(security): logging hygiene — no bodies/PII/full errors in logs
dherrero Jun 9, 2026
809439c
fix(security): pin nginx image + add clockTolerance to internal verify
dherrero Jun 9, 2026
bd02ec1
fix(security): harden gateway token flows (T-20)
dherrero Jun 9, 2026
fddae88
fix(security): fix tokenDecoded truthiness bug; document CSRF posture
dherrero Jun 9, 2026
39415e5
docs(security): document internal-token replay assumption (T-22)
dherrero Jun 9, 2026
42733e5
chore: update package lock
dherrero Jun 10, 2026
e0a0f20
style: fix prettier formatting in AGENTS.md and README_eng.md
dherrero Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@ POSTGRESDB_HOST=localhost
POSTGRESDB_PORT=5432
POSTGRESDB_DATABASE=your_db_name
POSTGRESDB_USER=postgres
POSTGRESDB_PASSWORD=password
# REQUIRED — there is no default. Set a strong value here AND before deploying.
# This dev placeholder is intentionally not a real-looking password; change it.
POSTGRESDB_PASSWORD=dev-only-change-me

# Dev-only admin bootstrap. Off by default — the schema seeds NO users.
# To create a local admin, set DEV_SEED_ADMIN=true and provide a bcrypt hash:
# bash scripts/gen-admin-hash.sh 'your-strong-password'
# Then paste the output into BOOTSTRAP_ADMIN_PASSWORD_HASH below.
# NEVER enable this in production.
DEV_SEED_ADMIN=false
BOOTSTRAP_ADMIN_EMAIL=
BOOTSTRAP_ADMIN_PASSWORD_HASH=

# Public JWT secrets used by the gateway. Access and refresh tokens are
# signed with separate secrets so the compromise of one does not let an
Expand All @@ -12,6 +23,9 @@ JWT_ACCESS_SECRET=dev-access-secret-replace-me
JWT_REFRESH_SECRET=dev-refresh-secret-replace-me
JWT_EXPIRES_IN=4h
JWT_REFRESH_EXPIRES_IN=8h
# "remember me" refresh lifetime in days. Kept bounded (default 30) and backed
# by refresh-token rotation so revoked sessions die quickly. Avoid huge values.
JWT_REFRESH_REMEMBER_DAYS=30

# Internal JWT — Ed25519 asymmetric. The gateway holds the private key
# and signs gateway → api tokens. The api only holds the public key, so
Expand All @@ -32,6 +46,13 @@ INTERNAL_JWT_PUBLIC_KEY=
GATEWAY_PORT=3100
API_BASE_URL=http://api:3200

# Auth rate limiting (anti brute-force / credential stuffing). Login is keyed by
# IP+email and only counts failed attempts.
LOGIN_RATE_WINDOW_MS=900000
LOGIN_RATE_MAX=10
AUTH_RATE_WINDOW_MS=900000
AUTH_RATE_MAX=100

# API settings
NODE_PORT=3200
NODE_ENV=development
Expand All @@ -58,4 +79,5 @@ DB_POOL_EVICT=1000
DB_CONNECT_TIMEOUT=60000
DB_ACQUIRE_TIMEOUT=60000
DB_TIMEOUT=60000
HASH_SALT_ROUNDS=10
# bcrypt cost factor. Minimum enforced in code is 12; raise as hardware allows.
HASH_SALT_ROUNDS=12
19 changes: 12 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,16 @@ jobs:
- name: 📥 Install dependencies
run: npm ci

- name: 🔒 Run Security Audit
run: npm audit --audit-level moderate || true
continue-on-error: true

- name: 📊 Generate Audit Report
# Blocks the pipeline on high/critical advisories in PRODUCTION deps —
# the code that actually ships. Dev/build-tool advisories are reported but
# not gated (they churn constantly and can't be fixed without major
# toolchain bumps). Tighten with `npm audit fix` when prod issues appear.
- name: 🔒 Run Security Audit (production deps)
run: npm audit --omit=dev --audit-level=high

- name: 📊 Generate Full Audit Report
if: always()
run: npm audit --json > audit-report.json || true
continue-on-error: true

- name: 📤 Upload Audit Report
uses: actions/upload-artifact@v4
Expand Down Expand Up @@ -162,7 +165,9 @@ jobs:
notify:
name: 📢 Notify
runs-on: ubuntu-latest
needs: [lint, test, build, security, docker]
# build & docker are currently disabled (commented out below); keep this
# list in sync with the jobs that actually run or the graph is invalid.
needs: [lint, test, security]
if: failure()
steps:
- name: 📢 Notify Failure
Expand Down
70 changes: 35 additions & 35 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
name: 🏷️ Release

# Releases are DELIBERATE, not automatic. Running on every push to main meant a
# privileged (contents:write) job with a push-capable token ran on each merge —
# a supply-chain footgun: any pre-push step (npm lifecycle script, dependency)
# could abuse the persisted credential to push to main. Now a maintainer
# triggers it manually and picks the bump type.
on:
push:
branches: [main]
workflow_dispatch:
inputs:
bump:
description: Semver bump type
required: true
default: patch
type: choice
options:
- patch
- minor
- major

# Least privilege by default; the release job elevates only what it needs.
permissions:
contents: read

jobs:
release:
name: 🏷️ Version & Release
runs-on: ubuntu-latest
# Skip version bump commits pushed by this same workflow
if: "!contains(github.event.head_commit.message, '[skip ci]') && github.actor != 'github-actions[bot]'"
permissions:
contents: write

Expand All @@ -18,56 +34,40 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
# Do NOT leave the auth token in the local git config where any later
# step / lifecycle script could reuse it. The push step injects it.
persist-credentials: false

- name: 📦 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22.12.0'

# ── Determine bump type from the commit/PR title ──────────────────────────
# feat!: or feat(<scope>)!: → MAJOR
# feat: or feat(<scope>): → MINOR
# fix: or fix(<scope>): → PATCH
# anything else → no release
- name: 🔍 Determine bump type
id: bump
run: |
MSG=$(git log -1 --pretty=%s)
echo "Commit message: $MSG"
if echo "$MSG" | grep -qE '^feat(\(.+\))?!:'; then
echo "type=major" >> $GITHUB_OUTPUT
elif echo "$MSG" | grep -qE '^feat(\(.+\))?:'; then
echo "type=minor" >> $GITHUB_OUTPUT
elif echo "$MSG" | grep -qE '^fix(\(.+\))?:'; then
echo "type=patch" >> $GITHUB_OUTPUT
else
echo "type=none" >> $GITHUB_OUTPUT
fi

# --ignore-scripts: never run project/dependency lifecycle scripts on the
# privileged runner before we push.
- name: 🔢 Bump package version
if: steps.bump.outputs.type != 'none'
run: npm version ${{ steps.bump.outputs.type }} --no-git-tag-version

- name: 📌 Read new version
if: steps.bump.outputs.type != 'none'
id: version
run: echo "value=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
run: |
npm version ${{ inputs.bump }} --no-git-tag-version --ignore-scripts
echo "value=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"

- name: 📝 Commit, tag & push
if: steps.bump.outputs.type != 'none'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json
git commit -m "chore: release v${{ steps.version.outputs.value }} [skip ci]"
git tag v${{ steps.version.outputs.value }}
git push origin main --follow-tags
git tag "v${{ steps.version.outputs.value }}"
git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \
HEAD:main --follow-tags

- name: 🚀 Create GitHub Release
if: steps.bump.outputs.type != 'none'
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.version.outputs.value }}
name: v${{ steps.version.outputs.value }}
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30 changes: 15 additions & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,24 @@ Each package has its own `AGENTS.md` with rules specific to that layer. Read the
relevant one(s) before working in that directory — do **not** rely on this root file
for layer detail.

| Package | Guide | What lives there |
| ---------------------- | ------------------------------ | --------------------------------------------------------- |
| `apps/api` | `apps/api/AGENTS.md` | Express routes, Sequelize models, services, `HttpResponser`, JWT |
| `apps/gateway` | `apps/gateway/AGENTS.md` | Reverse proxy, public auth, EdDSA internal-token signing |
| `apps/front` | `apps/front/AGENTS.md` | Angular components, signals, routing, forms, i18n |
| `libs/rest-dto` | `libs/rest-dto/AGENTS.md` | Shared DTOs and API contracts (the single source of truth) |
| `libs/internal-auth` | `libs/internal-auth/AGENTS.md` | EdDSA sign/verify for service-to-service auth |
| `db` | `db/AGENTS.md` | SQL migration files and naming conventions |
| Package | Guide | What lives there |
| -------------------- | ------------------------------ | ---------------------------------------------------------------- |
| `apps/api` | `apps/api/AGENTS.md` | Express routes, Sequelize models, services, `HttpResponser`, JWT |
| `apps/gateway` | `apps/gateway/AGENTS.md` | Reverse proxy, public auth, EdDSA internal-token signing |
| `apps/front` | `apps/front/AGENTS.md` | Angular components, signals, routing, forms, i18n |
| `libs/rest-dto` | `libs/rest-dto/AGENTS.md` | Shared DTOs and API contracts (the single source of truth) |
| `libs/internal-auth` | `libs/internal-auth/AGENTS.md` | EdDSA sign/verify for service-to-service auth |
| `db` | `db/AGENTS.md` | SQL migration files and naming conventions |

### TypeScript path aliases (from `tsconfig.base.json`)

| Alias | Resolves to |
| ----------------- | ---------------------------- |
| `@dto` | `libs/rest-dto/src/index.ts` |
| `@internal-auth` | `libs/internal-auth/src/index.ts` |
| `@front/*` | `apps/front/src/*` |
| `@api/*` | `apps/api/src/*` |
| `@gateway/*` | `apps/gateway/src/*` |
| Alias | Resolves to |
| ---------------- | --------------------------------- |
| `@dto` | `libs/rest-dto/src/index.ts` |
| `@internal-auth` | `libs/internal-auth/src/index.ts` |
| `@front/*` | `apps/front/src/*` |
| `@api/*` | `apps/api/src/*` |
| `@gateway/*` | `apps/gateway/src/*` |

---

Expand Down
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,24 @@ npm run dev

### Usuario por Defecto

El esquema **no siembra ningún usuario**. Por seguridad no existen credenciales
por defecto. Para crear un administrador local en desarrollo, genera un hash y
habilita el seed (sólo dev):

```bash
# 1. Genera el hash bcrypt de tu contraseña
bash scripts/gen-admin-hash.sh 'tu-contraseña-fuerte'

# 2. En tu .env
DEV_SEED_ADMIN=true
BOOTSTRAP_ADMIN_EMAIL=admin@example.test
BOOTSTRAP_ADMIN_PASSWORD_HASH=<el hash generado>

# 3. Recrea la base de datos para que corra el seed
npm run dev:db:clean && npm run dev:db
```
Email: test@local.com
Contraseña: 123456
```

> Nunca habilites `DEV_SEED_ADMIN` en producción.

## 🛠️ Comandos de Desarrollo

Expand Down
9 changes: 9 additions & 0 deletions apps/api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ routes → controllers → services → models → (db via Sequelize)
- **Never expose `password`.** `AbstractCrudService` excludes `password` from every
read, and excludes `deleted`/`deletedAt` unless `where.deleted` is set.
- Models import their shape from `@dto`; do not duplicate field types locally.
- **Validate every client input.** Never pass `req.body` / `req.query` / `req.params`
to a service unvalidated. Put a `validate(schema, source)` middleware
(`src/middleware/validate.middleware.ts`) in front of the controller, using a
Zod schema from `@dto` (`userCreateSchema`, `userUpdateSchema`,
`paginationQuerySchema`, `idParamSchema`, …). Input schemas are `.strict()`, so
unknown keys are rejected at the edge — this, plus the service-layer
`writableFields` allow-list, is the mass-assignment defense. Because `req.query`
is a read-only getter in Express 5, the parsed query is exposed on
`res.locals.query` (read it from there), while body/params are replaced in place.

## Internal auth

Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/adapters/db/pg.connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ const dbConfig = {
database: process.env.POSTGRESDB_DATABASE ?? 'your_db_name',
username: process.env.POSTGRESDB_USER ?? 'postgres',
password: process.env.POSTGRESDB_PASSWORD ?? 'password',
logging: process.env.NODE_PRODUCTION === 'true' ? false : console.log,
// Fail-safe: never log SQL (which includes parameter values like emails) when
// either standard NODE_ENV or the legacy NODE_PRODUCTION flag indicates prod.
logging:
process.env.NODE_ENV === 'production' ||
process.env.NODE_PRODUCTION === 'true'
? false
: console.log,
pool: {
max: parseInt(process.env.DB_POOL_MAX || '10'),
min: parseInt(process.env.DB_POOL_MIN || '0'),
Expand Down
90 changes: 90 additions & 0 deletions apps/api/src/controllers/abstract-crud.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from 'vitest';
import { AbstractCrudController } from './abstract-crud.controller';

class TestController extends AbstractCrudController {
constructor(service: never) {
super(service);
}
}

const makeRes = () => {
const res: Record<string, unknown> = { locals: {} };
res.status = vi.fn().mockReturnValue(res);
res.json = vi.fn().mockReturnValue(res);
res.send = vi.fn().mockReturnValue(res);
return res as never;
};

describe('AbstractCrudController', () => {
describe('getAllPaged', () => {
it('clamps invalid pagination to sane defaults', async () => {
const service = {
getAllPaged: vi.fn().mockResolvedValue({ rows: [], count: 0 }),
};
const ctrl = new TestController(service as never);

await ctrl.getAllPaged(
{ query: { page: 'abc', limit: '99999' } } as never,
makeRes(),
);

// Bad page -> 1; limit clamped to <=100 falls back to default 10 here.
expect(service.getAllPaged).toHaveBeenCalledWith(1, 10);
});

it('honours valid pagination', async () => {
const service = {
getAllPaged: vi.fn().mockResolvedValue({ rows: [], count: 0 }),
};
const ctrl = new TestController(service as never);

await ctrl.getAllPaged(
{ query: { page: '2', limit: '25' } } as never,
makeRes(),
);

expect(service.getAllPaged).toHaveBeenCalledWith(2, 25);
});
});

describe('put', () => {
it('404s and does not update a soft-deleted/absent row', async () => {
const service = {
getById: vi.fn().mockResolvedValue(null),
put: vi.fn(),
};
const ctrl = new TestController(service as never);
const res = makeRes();

await ctrl.put({ params: { id: '7' }, body: {} } as never, res);

expect(service.getById).toHaveBeenCalledWith({ id: '7', deleted: false });
expect(service.put).not.toHaveBeenCalled();
expect(
(res as { status: ReturnType<typeof vi.fn> }).status,
).toHaveBeenCalledWith(404);
});

it('updates and reads back an existing row with deleted:false', async () => {
const service = {
getById: vi
.fn()
.mockResolvedValueOnce({ id: 7 })
.mockResolvedValueOnce({ id: 7, name: 'new' }),
put: vi.fn().mockResolvedValue([1]),
};
const ctrl = new TestController(service as never);

await ctrl.put(
{ params: { id: '7' }, body: { name: 'new' } } as never,
makeRes(),
);

expect(service.put).toHaveBeenCalledWith('7', { name: 'new' });
expect(service.getById).toHaveBeenLastCalledWith({
id: '7',
deleted: false,
});
});
});
});
Loading
Loading