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:
- Remove the branch that lets a
social:truerequest match onm.sidalone (routes/routes.js:6540). Social logins must be verified by the OAuth/Passport callback that sets the session, not by an email+sid lookup. - Delete the plaintext-password fallback
else if (reqObj.password == result[0].password)atroutes/routes.js:6603— authentication must succeed only on thebcrypt.comparetrue branch. - Move the
allow-loginpolicy 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):
- Replace every
${reqObj.email}/${reqObj.sid}interpolation with?placeholders and pass the values as a bound array (see the Input Validation section below for thequeryAll+paramspattern and exact line targets). - Add
registerVal-style validation (isEmail(),notEmpty()) at the route boundary before the query runs. - 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):
- 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 });`. - While here, fix the plaintext password match at
routes/routes.js:3039(AND password='${reqObj.password}'): drop it from the SQL, select the row bysidonly with a placeholder, and verify withbcrypt.compare(reqObj.password, row.password).
See authentication.md § Standard Login.
CORS¶
server.js applies CORS with default options:
This is equivalent to:
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.
- Add an allow-list env var to
.envand surface it inconf.js(alongside the otherprocess.env.*exports, e.g. afterconf.js:32): - Replace
server.js:145with an origin-checking config: - Move this
app.use(cors())call ahead of the route-mounting block (currentlycors()runs atserver.js:145, just before routes load atserver.js:151) — and ideally ahead ofpassport.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.
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.
- Require it at the top of
server.js(near the other middleware requires,server.js:1-10): - Mount it as the first middleware, before
express-session(server.js:12): This setsX-Content-Type-Options,X-Frame-Options,Strict-Transport-Security,Referrer-Policy, etc. - This API serves JSON, not HTML, so the default CSP can be disabled to avoid breaking nothing-to-protect responses:
- 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.
- Install:
- Define a shared limiter once, near the top of
routes/routes.js(therouteris created atroutes/routes.js:94): - Apply it to each credential-accepting route. The relevant handlers in this repo:
/login—routes/routes.js:3024→router.post("/login", authLimiter, async (req, res, next) => {/authenticate—routes/routes.js:6387/webauthenticate—routes/routes.js:6522/register—routes/routes.js:2421and/netregister—routes/routes.js:2749- Add a separate, more generous limiter for the AI-generation endpoints to cap Gemini/Bedrock spend (e.g.
max: 30per window per IP/user). - 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:
(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: 50000is high; default is 1000.
Fix¶
The 150 MB global limit is set in the conditional body-parser at server.js:24.
- Lower the global limit and bring
parameterLimitback toward the default. Changeserver.js:24from: to: (extendedis aurlencodedoption and is ignored byexpress.json; drop it.) - For the few routes that legitimately accept large base64 JSON payloads, apply a per-route override instead of widening the global limit:
- Prefer multipart uploads through
express-fileupload(already mounted atserver.js:18) over base64-in-JSON; route genuinely large binary uploads there so they never hit the JSON parser. - 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) andPOST /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. Seeauthentication.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)):
Part B — parameterize the login SQL. Replace the string-concatenated conditions with ? placeholders and pass values via reqObj.params:
/authenticate—routes/routes.js:6393-6403. Change: to:/webauthenticate—routes/routes.js:6529-6548. Same treatment form.username = ?/m.sid = ?, withreqObj.params = [reqObj.email, reqObj.sid]./login—routes/routes.js:3037,3039. Select bysid = ?only; do not put the password in the query (see Sessions & Cookies / authentication.md for the bcrypt fix).- The direct
con.querycalls inside/webauthenticate(routes/routes.js:6554,:6567,:6568) and theUPDATE tMember … WHERE id=${result[0].id}writes (routes/routes.js:6422,:6594,:6604) also interpolate values — convert them tocon.query('… WHERE id = ?', [result[0].id]).conis thesync-mysqlclient created atroutes/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(),
];
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.
- Add the keys to
.envand surface them inconf.js(append to the export block, e.g. afterconf.js:59): - Slack token: add a single shared accessor (e.g. in
helper/) that returnsconf.SLACK_BOT_TOKEN, then replace the 19 inlineconst token = 'xoxb-…'literals (starting atjob_color_check.js:11) with that accessor so rotation is one env change. See Notifications. - Polotno key: replace the 49 inline
key: 'FXZvloSJvAe09-bdR9iC'occurrences (e.g.job_design_generation.js:119) withkey: conf.POLOTNO_KEY. - Session secret: change
server.js:13fromsecret: "3eB(2:\srlI+qa5"tosecret: conf.SESSION_SECRETand generate a fresh value (require('crypto').randomBytes(32).toString('hex')). - 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 -pto confirm no other secrets leaked. Add.envto.gitignoreif 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.
- Move the secret to
.env/conf.js(SESSION_SECRET, as in the Secrets Management fix) and referenceconf.SESSION_SECRETatserver.js:13. - 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: truerequires HTTPS — confirmed correct because nginx terminates TLS in production; gate it onprocess.env.NODE_ENV === 'production'so local HTTP dev still works.) - 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):
express-session(with hardcoded secret)express-fileupload()- Conditional
express.json({ limit: '150mb' }) - Static file routes (
/favicon.ico,/uploads) - Healthcheck endpoints (
/health,/db-health) passport.initialize()+passport.session()cors()(default — accepts all origins)- 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 bycors()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.
- Apply CORS (and
helmet) first. Moveapp.use(cors(...))fromserver.js:145to the top of the middleware stack, ahead ofexpress-session(server.js:12). Withhelmetfrom the Helmet fix, the intended top-of-stack order is:helmet()→cors()→express-session→fileUpload→ body parser. - Add a global error handler as the last
app.use, after routes are mounted (i.e. afterserver.js:173):Stack traces must not leak in production. See Error Handling.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, }); }); - Add a request logger as the first middleware (before
helmet), e.g.morganwith 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:
- Add rate limiting on
/auth/*endpoints — biggest exposure today. - Move hardcoded secrets to
.env(Slack, Polotno, session) and rotate. - Add helmet — one line, immediate protection.
- Tighten CORS to a domain allow-list.
- Reduce the JSON body limit to ~1 MB globally; raise per-route only where needed.
- Add a global error handler that strips stack traces in prod.
- Validate inputs at the route boundary — not in the handler.
- Add request logging with correlation IDs.
- 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.confin this repo is the in-container config) redirects HTTP→HTTPS and setsStrict-Transport-Security; if any header overlaps with thehelmet()defaults added above, let one layer own each header to avoid duplicates. The app'ssecure: truesession cookie (Sessions & Cookies fix) depends on this being in place.
Related¶
- Authentication & Security — JWT + AES token model, OAuth flows
- Configuration Reference — full env-var inventory
- Error Handling & Response Patterns
- Logging & Observability
- Notifications — context on the hardcoded Slack token
- Architecture Overview — full middleware stack