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
🔴 Login auth bypass Critical — POST /webauthenticate social:true issues a token for any social account given only the victim's email; password never validated. See authentication.md § Web Login.
🔴 SQL injection (login) Critical — /webauthenticate & /authenticate interpolate raw ${email}/${sid}; unauthenticated, UNION leaks password hashes.
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)

Fixes for the login rows (cross-linked)

The two 🔴 login rows and the /auth/login token-format row share a single root cause — string-concatenated SQL plus a non-cryptographic token. The full write-up (findings F1–F5, with code) is in authentication.md § Web Login. The key steps, summarised:

Fix — auth bypass (POST /webauthenticate social:true), routes/routes.js:6539-6547:

  1. Remove the branch that lets a social:true request match on m.sid alone (routes/routes.js:6540). Social logins must be verified by the OAuth/Passport callback that sets the session, not by an email+sid lookup.
  2. Delete the plaintext-password fallback else if (reqObj.password == result[0].password) at routes/routes.js:6603 — authentication must succeed only on the bcrypt.compare true branch.
  3. Move the allow-login policy check ahead of any token issuance so a failed policy check cannot fall through to a success path.

See authentication.md § Web Login for the full corrected handler.

Fix — SQL injection (/webauthenticate routes/routes.js:6529-6548, /authenticate routes/routes.js:6393-6403):

  1. Replace every ${reqObj.email} / ${reqObj.sid} interpolation with ? placeholders and pass the values as a bound array (see the Input Validation section below for the queryAll + params pattern and exact line targets).
  2. Add registerVal-style validation (isEmail(), notEmpty()) at the route boundary before the query runs.
  3. After fixing, grep the file for the remaining con.query(\… ${...}`)calls in these handlers (routes/routes.js:6554,:6567,:6568`) and parameterize them too.

See authentication.md § Web Login (F1–F5).

Fix — /auth/login plaintext token format (routes/routes.js:3060):

  1. Replace let access_token = \${result.id}${Date.now()}${result.id}`;(routes/routes.js:3060) with the existing AES token helper already imported atroutes/routes.js:104:let access_token = encryptData({ userId: result.id, defaultPaymentId: result.defaultPaymentId });`.
  2. While here, fix the plaintext password match at routes/routes.js:3039 (AND password='${reqObj.password}'): drop it from the SQL, select the row by sid only with a placeholder, and verify with bcrypt.compare(reqObj.password, row.password).

See authentication.md § Standard Login.


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.

Fix

The bare app.use(cors()); is at server.js:145.

  1. Add an allow-list env var to .env and surface it in conf.js (alongside the other process.env.* exports, e.g. after conf.js:32):
    # .env
    ALLOWED_ORIGINS=https://app.someli.ai,https://www.someli.ai
    
    // conf.js
    ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS,
    
  2. Replace server.js:145 with an origin-checking config:
    const ALLOWED_ORIGINS = (conf.ALLOWED_ORIGINS || '').split(',').map(o => o.trim()).filter(Boolean);
    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,
    }));
    
  3. Move this app.use(cors()) call ahead of the route-mounting block (currently cors() runs at server.js:145, just before routes load at server.js:151) — and ideally ahead of passport.initialize() (server.js:141) — so CORS headers and preflight handling apply before any handler runs. See the Middleware Stack section.

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.

Fix

helmet is already a dependency (package.json:54, "helmet": "^8.1.0") — it just isn't wired in. No install is required.

  1. Require it at the top of server.js (near the other middleware requires, server.js:1-10):
    const helmet = require('helmet');
    
  2. Mount it as the first middleware, before express-session (server.js:12):
    app.use(helmet());
    
    This sets X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, Referrer-Policy, etc.
  3. This API serves JSON, not HTML, so the default CSP can be disabled to avoid breaking nothing-to-protect responses:
    app.use(helmet({ contentSecurityPolicy: false }));
    
  4. Confirm nginx (in front in production — see Deployment) is not also setting conflicting/duplicate headers; if it is, pick one layer to own each header.

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.

Fix

express-rate-limit is not currently a dependency (absent from package.json), so install it first.

  1. Install:
    yarn add express-rate-limit
    
  2. Define a shared limiter once, near the top of routes/routes.js (the router is created at routes/routes.js:94):
    const rateLimit = require('express-rate-limit');
    const authLimiter = rateLimit({
      windowMs: 15 * 60 * 1000, // 15 min
      max: 10,                  // 10 attempts per IP per window
      standardHeaders: true,
      legacyHeaders: false,
      message: { status: false, code: 429, message: 'Too many attempts, try again later.' },
    });
    
  3. Apply it to each credential-accepting route. The relevant handlers in this repo:
  4. /login — routes/routes.js:3024 → router.post("/login", authLimiter, async (req, res, next) => {
  5. /authenticate — routes/routes.js:6387
  6. /webauthenticate — routes/routes.js:6522
  7. /register — routes/routes.js:2421 and /netregister — routes/routes.js:2749
  8. Add a separate, more generous limiter for the AI-generation endpoints to cap Gemini/Bedrock spend (e.g. max: 30 per window per IP/user).
  9. If running multiple Node processes behind nginx, back the limiter with a shared store (rate-limit-redis) so the count is global, not per-process; otherwise also confirm whether nginx/AWS WAF already enforces a limit and tune accordingly.

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.

Fix

The 150 MB global limit is set in the conditional body-parser at server.js:24.

  1. Lower the global limit and bring parameterLimit back toward the default. Change server.js:24 from:
    express.json({ limit: '150mb', extended: true, parameterLimit: 50000 })(req, res, next);
    
    to:
    express.json({ limit: '1mb', parameterLimit: 1000 })(req, res, next);
    
    (extended is a urlencoded option and is ignored by express.json; drop it.)
  2. For the few routes that legitimately accept large base64 JSON payloads, apply a per-route override instead of widening the global limit:
    router.post('/uploadBase64Image', express.json({ limit: '25mb' }), handler);
    
  3. Prefer multipart uploads through express-fileupload (already mounted at server.js:18) over base64-in-JSON; route genuinely large binary uploads there so they never hit the JSON parser.
  4. After lowering the limit, smoke-test the known large-payload endpoints (image/design save) to confirm 1 MB is sufficient for everything except the explicitly-overridden routes.

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.
  • Many queries are parameterized, but the primary login endpoints are not: POST /webauthenticate (routes/routes.js:6529-6548) and POST /authenticate (:6401) concatenate raw ${email}/${sid} into the SQL string — unauthenticated SQL injection that also leaks password hashes via UNION. Do not rely on the prior "mostly mitigated" assumption for auth/login handlers. See authentication.md § Web Login (F1–F5). Treat string-concatenated SQL anywhere as a live risk and audit before extending.
  • The agents and AI helpers pass user input directly into prompts. There's no prompt-injection protection.

Fix

Part A — make the queryAll wrapper parameter-aware so handlers can pass bound values instead of interpolating. The wrapper currently calls db.query(reqObj.q, callback) at actions/actions.js:187. Extend it to forward an optional reqObj.params array (mysql/mysql2 both accept (sql, values, cb)):

// actions/actions.js ~line 187
db.query(reqObj.q, reqObj.params || [], (err, result) => { ... });

Part B — parameterize the login SQL. Replace the string-concatenated conditions with ? placeholders and pass values via reqObj.params:

  1. /authenticate — routes/routes.js:6393-6403. Change:
    reqObj.q = `SELECT m.id,m.sid,... FROM tMember m LEFT JOIN tRoles role ON m.role_type = role.id WHERE m.sid='${reqObj.email}' AND m.isDeleted=0`;
    
    to:
    reqObj.q = `SELECT m.id,m.sid,... FROM tMember m LEFT JOIN tRoles role ON m.role_type = role.id WHERE m.sid = ? AND m.isDeleted = 0`;
    reqObj.params = [reqObj.email];
    
  2. /webauthenticate — routes/routes.js:6529-6548. Same treatment for m.username = ? / m.sid = ?, with reqObj.params = [reqObj.email, reqObj.sid].
  3. /login — routes/routes.js:3037,3039. Select by sid = ? only; do not put the password in the query (see Sessions & Cookies / authentication.md for the bcrypt fix).
  4. The direct con.query calls inside /webauthenticate (routes/routes.js:6554, :6567, :6568) and the UPDATE tMember … WHERE id=${result[0].id} writes (routes/routes.js:6422, :6594, :6604) also interpolate values — convert them to con.query('… WHERE id = ?', [result[0].id]). con is the sync-mysql client created at routes/routes.js:86; it accepts the same (sql, values) placeholder form.

Part C — validate at the route boundary. For any new endpoint, define a validator chain in middlewares/validation.js (the file currently exports only registerVal, middlewares/validation.js:10-15) and apply it before the handler. Add a login validator alongside it:

// middlewares/validation.js
exports.loginVal = [
  check('email', 'Please include a valid email').isEmail().normalizeEmail({ gmail_remove_dots: true }),
  check('password', 'Password is required').notEmpty(),
];
Wire it onto the login routes (routes/routes.js:3024, :6387, :6522) and check validationResult(req) at the top of each handler. 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.

Fix

The three hardcoded secrets are spread across these locations (verified by grep): the Slack bot token appears in 19 job files (e.g. job_color_check.js:11), the Polotno key in 49 files (e.g. job_design_generation.js:119), and the session secret in server.js:13. conf.js (currently conf.js:3-59) has no entry for any of them.

  1. Add the keys to .env and surface them in conf.js (append to the export block, e.g. after conf.js:59):
    SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN,
    POLOTNO_KEY: process.env.POLOTNO_KEY,
    SESSION_SECRET: process.env.SESSION_SECRET,
    
  2. Slack token: add a single shared accessor (e.g. in helper/) that returns conf.SLACK_BOT_TOKEN, then replace the 19 inline const token = 'xoxb-…' literals (starting at job_color_check.js:11) with that accessor so rotation is one env change. See Notifications.
    grep -rln "xoxb-" *.js   # the 19 files to update
    
  3. Polotno key: replace the 49 inline key: 'FXZvloSJvAe09-bdR9iC' occurrences (e.g. job_design_generation.js:119) with key: conf.POLOTNO_KEY.
    grep -rln "FXZvloSJvAe09" *.js   # the 49 files to update
    
  4. Session secret: change server.js:13 from secret: "3eB(2:\srlI+qa5" to secret: conf.SESSION_SECRET and generate a fresh value (require('crypto').randomBytes(32).toString('hex')).
  5. Rotate and verify history: all three were committed in plaintext, so rotate/revoke them at the source (Slack workspace token, Polotno license, session secret) and audit git log -p to confirm no other secrets leaked. Add .env to .gitignore if not already present.

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.

Fix

The express-session block is at server.js:12-16 with the hardcoded secret on server.js:13 and saveUninitialized: true on server.js:15.

  1. Move the secret to .env/conf.js (SESSION_SECRET, as in the Secrets Management fix) and reference conf.SESSION_SECRET at server.js:13.
  2. Set saveUninitialized: false (server.js:15) so empty sessions are not persisted, and add secure cookie flags:
    app.use(expressSession({
      secret: conf.SESSION_SECRET,
      resave: false,
      saveUninitialized: false,
      cookie: { httpOnly: true, secure: true, sameSite: 'lax' },
    }));
    
    (secure: true requires HTTPS — confirmed correct because nginx terminates TLS in production; gate it on process.env.NODE_ENV === 'production' so local HTTP dev still works.)
  3. Because sessions exist only for the OAuth/Passport redirect dance, no further hardening (server-side store, etc.) is needed for the token-in-header API paths — leave those on the header model.

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.

Fix

In the actual source, express-session is mounted at server.js:12, the body parser at server.js:24, static routes at server.js:28-29, and passport.initialize() / passport.session() / cors() are deferred into startServer() at server.js:141-145, just before routes load at server.js:151-173.

  1. Apply CORS (and helmet) first. Move app.use(cors(...)) from server.js:145 to the top of the middleware stack, ahead of express-session (server.js:12). With helmet from the Helmet fix, the intended top-of-stack order is: helmet() → cors() → express-session → fileUpload → body parser.
  2. Add a global error handler as the last app.use, after routes are mounted (i.e. after server.js:173):
    app.use((err, req, res, next) => {
      console.error(err);
      const code = err.status || 500;
      res.status(code).json({
        status: false,
        code,
        message: process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message,
      });
    });
    
    Stack traces must not leak in production. See Error Handling.
  3. Add a request logger as the first middleware (before helmet), e.g. morgan with a correlation-ID per request, so logs are traceable. 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.
  9. Verify HTTPS/TLS at the edge. TLS is terminated by nginx, not the app, so the fix lives outside this repo. Confirm the deployed nginx vhost (nginx.conf in this repo is the in-container config) redirects HTTP→HTTPS and sets Strict-Transport-Security; if any header overlaps with the helmet() defaults added above, let one layer own each header to avoid duplicates. The app's secure: true session cookie (Sessions & Cookies fix) depends on this being in place.