Skip to content

Security (Beyond Authentication)

This document covers cross-cutting security concerns: CORS, input validation, body limits, helmet/security headers, rate limiting, secrets management, and session handling. Authentication itself is documented separately — see Authentication & Security.

The state of the codebase here is uneven. Several common protections (helmet, rate limiting, CORS allow-list) are not configured. This document is honest about what's missing as well as what's present.


Quick Audit

Concern State
CORS ⚠️ cors() with no options — accepts all origins
Helmet ❌ Not loaded
Rate limiting ❌ No express-rate-limit / slowDown middleware
Body size limit ⚠️ 150 MB JSON payload limit — very high
Input validation ⚠️ express-validator available, used in one route (registration)
Secrets in .env ✅ Most secrets via conf.js
Hardcoded secrets ❌ Slack token, Polotno key, session secret hardcoded
/auth/login token format ❌ Plaintext concatenation ${userId}_${timestamp}_${userId} — no encryption, no signature, no integrity check. Anyone reading the token in any log can extract the user ID and forge tokens. See authentication.md § Standard Login.
Session cookies ⚠️ express-session with hardcoded secret
HTTPS / TLS (External — handled by nginx; verify in your deployment)

CORS

server.js applies CORS with default options:

const cors = require('cors');
app.use(cors());

This is equivalent to:

app.use(cors({ origin: '*' }));

Implications

  • Any origin can issue cross-origin requests to the API.
  • Browser will refuse to send credentials with Access-Control-Allow-Origin: *, so credentialed flows (cookies / auth headers) still need explicit origin handling.
  • The auth model is token-in-header, so wide-open CORS doesn't grant unauthenticated access — but it does mean any malicious site can attempt API calls on behalf of a logged-in user (CSRF-adjacent), and any error message details are visible cross-origin.

Recommendation

Replace with an allow-list:

app.use(cors({
  origin: (origin, cb) => {
    if (!origin || ALLOWED_ORIGINS.includes(origin)) cb(null, true);
    else cb(new Error('Not allowed by CORS'));
  },
  credentials: true,
}));

ALLOWED_ORIGINS should come from .env.


Helmet

Not loaded. No security headers are set by the application.

// server.js does NOT include:
const helmet = require('helmet');
app.use(helmet());

Headers Helmet would set that the API currently does not: - Strict-Transport-Security - X-Content-Type-Options: nosniff - X-Frame-Options: DENY - Content-Security-Policy (for HTML responses) - Referrer-Policy - X-DNS-Prefetch-Control - etc.

If nginx is in front of the API (it is in production — see Deployment), some headers may be set there. Verify your nginx config; do not assume.

Recommendation

Add helmet with default settings. The defaults are safe for an API; CSP can be skipped if no HTML is served.


Rate Limiting

No rate limiting. No express-rate-limit, slowDown, or any custom limiting middleware is present.

This means: - Authentication endpoints (/auth/login, /auth/register, password reset) are unrestricted — vulnerable to brute force and credential stuffing. - AI-generation endpoints can be hammered to drive up Gemini / Bedrock cost. - Public endpoints have no DoS protection at the application layer.

Some protection may exist at nginx, AWS WAF, or CDN level. Check your infrastructure.

Recommendation

At minimum, rate-limit /auth/login and /auth/register:

const rateLimit = require('express-rate-limit');
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10 });
router.post('/login', authLimiter, ...);

Body Size Limits

server.js configures body-parser with a 150 MB limit:

app.use(express.json({ limit: '150mb', parameterLimit: 50000 }));

(Webhook routes are excluded; they use a smaller default.)

Implications

  • Useful for large payloads — base64-encoded images uploaded via JSON.
  • Vulnerable to memory-exhaustion DoS — a single attacker can issue a 150 MB request and tie up memory while it's parsed.
  • The parameterLimit: 50000 is high; default is 1000.

Recommendation

If 150 MB is required for a specific endpoint (e.g., direct image upload), consider applying that limit only to that route via a router-level express.json({ limit: '150mb' }) and keeping the global limit at e.g. 1mb. Multipart uploads should go through express-fileupload (already in use), not JSON.


Input Validation

The codebase has express-validator installed and uses it in middlewares/validation.js:

const { check, body } = require('express-validator');

exports.registerVal = [
  check('username').isEmail().notEmpty(),
  check('password').isLength({ min: 6, max: 20 }).notEmpty(),
];

This is the only validator defined. It's wired into the registration endpoint only.

Practical state

  • Most endpoints rely on handler-level checks (if (!req.body.x) return ...) or simply trust the input.
  • SQL queries throughout the codebase use parameterized queries, so SQL injection is mostly mitigated by the DB driver — but any handler that string-concatenates user input into SQL is a risk. Spot-check before adding new query patterns.
  • The agents and AI helpers pass user input directly into prompts. There's no prompt-injection protection.

Recommendation

For any new endpoint, define a validator chain in middlewares/validation.js and apply it. Centralizing validation makes it auditable.


Secrets Management

What's done right

conf.js loads .env via dotenv and exports the values:

require('dotenv').config();
module.exports = {
  AWS_ACCESS_KEY: process.env.AWS_ACCESS_KEY,
  SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
  // ... ~72 keys
};

Most secrets (AWS, SendGrid, Gemini, Paddle, OAuth client secrets, JWT secret) flow through conf.js.

See Configuration Reference for the full list.

What's hardcoded (should not be)

Secret Where Risk
Slack bot token (xoxb-3144030948916-...) Inline in job_color_check.js, job_auto_disconnect.js, etc. Token revocation requires code change in every file
Polotno key (FXZvloSJvAe09-bdR9iC) Inline in ~49 files Same — sweep replace required to rotate
express-session secret ("3eB(2:\srlI+qa5") server.js line ~12 Anyone with source can forge session cookies

GCS credentials (handled correctly)

Google Cloud service-account JSON is not stored in the repo. It's fetched at runtime from AWS Secrets Manager (secret name from GCS_SECRET_NAME) and cached for 1 hour. See RAG Pipeline → Cloud RAG credentials.

Recommendations

  1. Move the Slack token to .env (SLACK_BOT_TOKEN) and add a shared helper that reads it. See Notifications.
  2. Move the Polotno key to .env (POLOTNO_KEY).
  3. Move the session secret to .env (SESSION_SECRET) and rotate it.
  4. Audit git log to confirm none of the above were ever committed in plaintext to a remote (they were — assume rotated and revoked).

Sessions & Cookies

server.js configures express-session near the top of the middleware stack:

app.use(session({
  secret: '3eB(2:\srlI+qa5',     // hardcoded
  resave: false,
  saveUninitialized: true,
  // (verify the actual options in source)
}));

cookie-parser is not loaded explicitly — express-session handles its own cookie.

What sessions are used for

Sessions are primarily used for OAuth callback flows (Passport requires them to maintain state during the redirect dance). The main API authentication is token-in-header, not cookies — see Authentication & Security.

Implications

  • The session cookie is mostly write-only from the API's perspective; the frontend never reads it.
  • A leaked session secret allows an attacker to forge OAuth-state cookies, but the impact is limited because the OAuth callback validates additional state parameters.

Recommendation

Move the secret to .env. Set cookie: { httpOnly: true, secure: true, sameSite: 'lax' } if not already set.


Middleware Stack — Order Matters

From server.js (top to bottom):

  1. express-session (with hardcoded secret)
  2. express-fileupload()
  3. Conditional express.json({ limit: '150mb' })
  4. Static file routes (/favicon.ico, /uploads)
  5. Healthcheck endpoints (/health, /db-health)
  6. passport.initialize() + passport.session()
  7. cors() (default — accepts all origins)
  8. Route mounting (auth, social, paddle, partnerAuth, dashboard, main API)

Issues with this order

  • CORS is loaded after express-session — preflight requests still work because they're handled by cors() before reaching the session middleware, but the order is unconventional. Convention is to apply CORS first.
  • No global error handler at the end — see Error Handling.
  • No request logger at the top — see Logging & Observability.

Defense-in-Depth Recommendations

If you're hardening this codebase, in priority order:

  1. Add rate limiting on /auth/* endpoints — biggest exposure today.
  2. Move hardcoded secrets to .env (Slack, Polotno, session) and rotate.
  3. Add helmet — one line, immediate protection.
  4. Tighten CORS to a domain allow-list.
  5. Reduce the JSON body limit to ~1 MB globally; raise per-route only where needed.
  6. Add a global error handler that strips stack traces in prod.
  7. Validate inputs at the route boundary — not in the handler.
  8. Add request logging with correlation IDs.