Skip to content

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

Fileapps/api/src/main.ts
Risk9/10
AttackCross-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 GET navigations that mutate state (if any exist)
  • Subdomains performing CSRF if cookies don't have a Domain restriction
  • 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

Filesapps/api/src/modules/auth/auth.service.ts, apps/api/src/common/guards/permissions.guard.ts
Risk9/10
AttackPrivilege 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:

  1. On role/permission changes, revoke all refresh tokens for affected users (revokeAllForUser)
  2. Or add a permissions version counter: store a permVersion in the JWT and in the DB role; the guard checks they match

3. Hardcoded Production Credentials in Docker Compose

Filedocker-compose.yml#L58-L61
Risk10/10
AttackCredential Exposure
yaml
# Lines 58-61 — production API service
- DATABASE_URL=postgresql://ptx_cm:ptx_cm_dev@postgres:5432/ptx_cm
- REDIS_PASSWORD=dev_redis_pw

The 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)

Fileapps/api/src/main.ts
Risk7/10

Zero usage of helmet in the entire API. Missing headers:

  • X-Content-Type-Options: nosniff
  • X-Frame-Options: DENY
  • Strict-Transport-Security (HSTS)
  • Content-Security-Policy
  • X-XSS-Protection
  • Referrer-Policy

Fix: npm i helmetapp.use(helmet()) in main.ts.


5. Nginx Serves HTTP Only — No TLS Termination

Filenginx/nginx.conf
Risk7/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

Fileapps/api/src/modules/auth/auth.service.ts#L16-L28
Risk8/10
AttackBrute 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

Filesapps/api/src/modules/users/dto/create-user.dto.ts, apps/api/src/modules/auth/dto/reset-password-with-token.dto.ts
Risk6/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)

Fileapps/api/src/modules/auth/auth.service.ts#L57
Risk4/10
typescript
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // hardcoded 7d

The 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

Fileapps/api/src/common/guards/country-scope.guard.ts#L20-L32
Risk5/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

Fileapps/api/src/common/filters/http-exception.filter.ts#L47
Risk4/10
typescript
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

Fileapps/api/entrypoint.sh
Risk6/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

AreaDetails
Password Hashingbcrypt with cost factor 10
JWT CookiesHttpOnly, Secure (prod), SameSite: Lax, path-restricted refresh cookie
Refresh Token RotationDB-backed JTI, old token deleted before new issued, transactional
Credential EncryptionAES-256-GCM with random 12-byte IV, auth tag verified
Input ValidationGlobal ValidationPipe with whitelist: true, forbidNonWhitelisted: true
SQL InjectionPrisma ORM used exclusively — no raw SQL detected
XSS (Server)No eval, exec, innerHTML, or dangerouslySetInnerHTML found
DockerNon-root user, STOPSIGNAL, health checks, .dockerignore
Password ResetSHA-256 hashed token, one-time use flag, expiry check, revokes all sessions
Ownership ChecksOTA accounts verify userId ownership before mutations
Sanitized OutputcredentialsEncrypted and twoFactorSecret stripped from API responses
ThrottlingGlobal 100 req/min + tighter limits on auth endpoints
Graceful ShutdownSIGTERM/SIGINT handlers, enableShutdownHooks()
.gitignore.env is properly excluded

Priority Remediation Roadmap

PriorityFindingEffort
P0#3 Hardcoded docker-compose credentials10 min
P0#4 Add Helmet5 min
P1#1 CSRF protection2–4 hrs
P1#2 Stale JWT permissions (revokeAll on role change)2 hrs
P1#6 Account lockout3–4 hrs
P2#5 Nginx TLS / verify upstream TLS termination1 hr
P2#7 Password complexity rules1 hr
P2#11 Seed script guard for production30 min
P3#8 Refresh token expiry from config15 min
P3#9 Document manager scope behavior15 min
P3#10 Structured logging for production2 hrs

PTX Channel Manager — Internal Documentation