Red-Team Security Audit — PTX Channel Manager
Date: 2026-03-05 | Scope: Full codebase (API, Web, DB, Infra)
Executive Summary
PTX-CM has a solid security foundation: JWT HttpOnly cookies, AES-256-GCM credential encryption, bcrypt hashing, refresh token rotation with DB-backed JTI revocation, global throttling, and Prisma ORM (no SQL injection). However, several medium-to-high severity gaps exist that an attacker could exploit, particularly around infrastructure hardening, stale authorization, and missing web security headers.
[9-10/10] CRITICAL Findings
1. No CSRF Protection
| File | apps/api/src/main.ts |
| Risk | 9/10 |
| Attack | Cross-Site Request Forgery |
Auth uses sameSite: 'lax' cookies — this protects against POST from cross-origin <form> on modern browsers, but does not protect against:
- Top-level
GETnavigations that mutate state (if any exist) - Subdomains performing CSRF if cookies don't have a
Domainrestriction - Older browser fallback
No CSRF tokens are generated or validated anywhere in the codebase. There is zero csrf usage across the API.
CAUTION
Exploit: An attacker crafts a page on evil.com that auto-submits a form to /api/v1/ota-accounts with PUT/DELETE. While sameSite: lax blocks cross-site POST in modern browsers, there's no defense-in-depth. If any mutation endpoint accepts GET parameters or if sameSite is ever relaxed, this becomes exploitable.
Fix: Add csurf middleware or implement a double-submit cookie pattern.
2. Stale JWT Permissions — Privilege Escalation Window
| Files | apps/api/src/modules/auth/auth.service.ts, apps/api/src/common/guards/permissions.guard.ts |
| Risk | 9/10 |
| Attack | Privilege Escalation / Privilege Persistence |
The JWT payload includes permissions, roleName, and country embedded at sign time (line 30–38). The PermissionsGuard reads permissions directly from the JWT (line 23), never checking the DB.
Impact: If an admin demotes a user or changes their role:
- The old JWT remains valid for up to 15 minutes (access token TTL)
- The refresh token rotation re-fetches the user from DB (good), but permissions are re-read from the DB role — so the 15-min window is the exposure
WARNING
Exploit: Admin revokes a user's DELETE permission. User's current access token still contains old permissions bitmask. For up to 15 minutes, the user can delete OTA accounts, properties, etc.
Fix: Either:
- On role/permission changes, revoke all refresh tokens for affected users (
revokeAllForUser) - Or add a permissions version counter: store a
permVersionin the JWT and in the DB role; the guard checks they match
3. Hardcoded Production Credentials in Docker Compose
| File | docker-compose.yml#L58-L61 |
| Risk | 10/10 |
| Attack | Credential Exposure |
# Lines 58-61 — production API service
- DATABASE_URL=postgresql://ptx_cm:ptx_cm_dev@postgres:5432/ptx_cm
- REDIS_PASSWORD=dev_redis_pwThe docker-compose.yml hardcodes ptx_cm_dev as the DB password and dev_redis_pw as the Redis password in the application service environment block (which is used for production builds). These are committed to git.
CAUTION
The Dokploy compose (docker-compose.dokploy.yml) correctly uses ${PG_PASSWORD} and ${REDIS_PASSWORD} env vars. But the main docker-compose.yml does not — anyone using it for deployment gets weak, known credentials.
Fix: Replace hardcoded credentials with ${PG_PASSWORD} env var references. Document that .env must be populated before deployment.
[7-8/10] HIGH Findings
4. No Security Headers (Helmet Missing)
| File | apps/api/src/main.ts |
| Risk | 7/10 |
Zero usage of helmet in the entire API. Missing headers:
X-Content-Type-Options: nosniffX-Frame-Options: DENYStrict-Transport-Security(HSTS)Content-Security-PolicyX-XSS-ProtectionReferrer-Policy
Fix: npm i helmet → app.use(helmet()) in main.ts.
5. Nginx Serves HTTP Only — No TLS Termination
| File | nginx/nginx.conf |
| Risk | 7/10 |
Nginx listens on port 80 only. No HTTPS. The secure: process.env.NODE_ENV === 'production' cookie flag will prevent cookies from being sent over HTTP in production, effectively breaking auth.
WARNING
In current deployment: either TLS is terminated upstream (Dokploy/reverse proxy) ✅ or cookies are never sent in production because Secure is true but transport is HTTP ❌.
Fix: If TLS is terminated upstream, add proxy_set_header X-Forwarded-Proto $scheme; and trust the proxy. If not, add TLS to nginx or remove the secure flag (not recommended).
6. No Account Lockout After Failed Login Attempts
| File | apps/api/src/modules/auth/auth.service.ts#L16-L28 |
| Risk | 8/10 |
| Attack | Brute Force |
The login endpoint has throttling (5 req/min), but no account lockout. After throttle window expires, attacker can retry indefinitely. No failed-attempt counter, no progressive delays, no CAPTCHA.
WARNING
Exploit: Rate limit of 5 req/min = 7,200 attempts/day. With admin123 as the seed password, this is trivially brute-forceable.
Fix: Track failed attempts per email in Redis. Lock account after N failures. Require CAPTCHA after 3 failures.
[4-6/10] MEDIUM Findings
7. Weak Password Policy
| Files | apps/api/src/modules/users/dto/create-user.dto.ts, apps/api/src/modules/auth/dto/reset-password-with-token.dto.ts |
| Risk | 6/10 |
Password validation is @MinLength(8) only. No requirements for:
- Uppercase/lowercase mix
- Numbers or special characters
- Not matching email/username
- Not being in a common password list
The seed passwords are admin123 and staff123 — trivially guessable.
Fix: Add @Matches() regex for complexity. Add mustChangePassword: true for seed accounts (this flag exists but seed sets it to default false).
8. Refresh Token Expiry Hardcoded (Not from Config)
| File | apps/api/src/modules/auth/auth.service.ts#L57 |
| Risk | 4/10 |
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // hardcoded 7dThe DB expiresAt is always 7 days, ignoring JWT_REFRESH_EXPIRY env var. If someone sets JWT_REFRESH_EXPIRY=1d, the JWT token expires in 1 day but the DB record persists for 7 — creating a window for token reuse after intended expiry (though JWT verification would catch it).
Fix: Parse JWT_REFRESH_EXPIRY and compute expiresAt from it.
9. Country Scope Bypass for Manager Users
| File | apps/api/src/common/guards/country-scope.guard.ts#L20-L32 |
| Risk | 5/10 |
Manager users (country == null) get no country scope unless they voluntarily pass ?country=XX. This means all queries for managers return all countries' data by default. While functionally correct, it means:
- A compromised manager JWT exposes ALL countries' data
- No query filtering is enforced for privileged users
10. Error Messages May Leak Information
| File | apps/api/src/common/filters/http-exception.filter.ts#L47 |
| Risk | 4/10 |
console.error('[GlobalExceptionFilter] Unhandled error:', exception);Non-HTTP exceptions are logged with full stack traces to stdout. In production, this could leak internal paths, dependency versions, or query structures to anyone with log access. The API response itself returns generic "Internal server error" (good), but the log verbosity is a concern.
11. Seed Data Runs in Production Entrypoint
| File | apps/api/entrypoint.sh |
| Risk | 6/10 |
The Dokploy compose uses entrypoint.sh which may run seeds on every deployment. If the seed script creates users with weak passwords (admin123), those accounts persist in production.
[1-3/10] LOW / Informational
12. 10MB Body Parser Limit
The API accepts up to 10MB payloads (json({ limit: '10mb' })). While needed for CSV imports, this increases DoS surface area. Consider adding per-route limits.
13. Mailpit Exposed in Dev Compose
Mailpit UI (port 8025) and SMTP (port 1025) are bound to 127.0.0.1 — safe for local dev. ✅
14. BullMQ Redis Connection Has No TLS
Redis connection in AppModule uses plain TCP. If Redis is on a separate network segment, credentials are transmitted in cleartext.
15. No Rate Limiting Per-User
Throttling is IP-based (global ThrottlerGuard). An attacker behind a botnet/VPN can bypass per-IP limits. Consider adding per-user-ID throttling for authenticated endpoints.
✅ What's Done Well
| Area | Details |
|---|---|
| Password Hashing | bcrypt with cost factor 10 |
| JWT Cookies | HttpOnly, Secure (prod), SameSite: Lax, path-restricted refresh cookie |
| Refresh Token Rotation | DB-backed JTI, old token deleted before new issued, transactional |
| Credential Encryption | AES-256-GCM with random 12-byte IV, auth tag verified |
| Input Validation | Global ValidationPipe with whitelist: true, forbidNonWhitelisted: true |
| SQL Injection | Prisma ORM used exclusively — no raw SQL detected |
| XSS (Server) | No eval, exec, innerHTML, or dangerouslySetInnerHTML found |
| Docker | Non-root user, STOPSIGNAL, health checks, .dockerignore |
| Password Reset | SHA-256 hashed token, one-time use flag, expiry check, revokes all sessions |
| Ownership Checks | OTA accounts verify userId ownership before mutations |
| Sanitized Output | credentialsEncrypted and twoFactorSecret stripped from API responses |
| Throttling | Global 100 req/min + tighter limits on auth endpoints |
| Graceful Shutdown | SIGTERM/SIGINT handlers, enableShutdownHooks() |
.gitignore | .env is properly excluded |
Priority Remediation Roadmap
| Priority | Finding | Effort |
|---|---|---|
| P0 | #3 Hardcoded docker-compose credentials | 10 min |
| P0 | #4 Add Helmet | 5 min |
| P1 | #1 CSRF protection | 2–4 hrs |
| P1 | #2 Stale JWT permissions (revokeAll on role change) | 2 hrs |
| P1 | #6 Account lockout | 3–4 hrs |
| P2 | #5 Nginx TLS / verify upstream TLS termination | 1 hr |
| P2 | #7 Password complexity rules | 1 hr |
| P2 | #11 Seed script guard for production | 30 min |
| P3 | #8 Refresh token expiry from config | 15 min |
| P3 | #9 Document manager scope behavior | 15 min |
| P3 | #10 Structured logging for production | 2 hrs |