Skip to content

Security

Findings

F-1: Hardcoded Slack bot token (HIGH)

teamsnotification.js:1:

const token = 'xoxb-3144030948916-4162895069441-K1RjRxBuP8k9z6rQAxnmyKS2'
const channel = 'C05H25MDY3Z'

A live Slack bot token in source. (Same pattern as Someli-admin-api/routes/auth.js, different token.)

Fix: 1. Rotate the token now in the Slack admin console (https://api.slack.com/apps → the bot's OAuth & Permissions → Revoke the old token, regenerate). A committed xoxb-… token must be treated as compromised even if the repo is private. 2. Add SLACK_BOT_TOKEN and SLACK_CHANNEL_ID to .env, and surface them in conf.js alongside the other keys (after line 25):

SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN,
SLACK_CHANNEL_ID: process.env.SLACK_CHANNEL_ID,
3. Replace teamsnotification.js:1-2:
const conf = require("./conf");
const token = conf.SLACK_BOT_TOKEN;
const channel = conf.SLACK_CHANNEL_ID;
(conf is already required at teamsnotification.js:7 — move that require above the token lines.) 4. git rm --cached the secret only matters for files that stay tracked; for a value that lived in committed history, rotation in step 1 is the real remediation. Optionally scrub history with git filter-repo once the token is dead.

F-2: Hardcoded Unsplash access key (HIGH)

routes/routes.js:47-50:

const unsplash = createApi({
  accessKey: 't0uIotWn5vRgDejBjDATYx6jY54WXihewTTtWzBxick',
  fetch: nodeFetch
});

The Unsplash API key is in source.

Fix: 1. Rotate the key at https://unsplash.com/oauth/applications (open the app, regenerate the Access Key). The exposed key is burned. 2. Add UNSPLASH_ACCESS_KEY to .env and to conf.js (after line 25):

UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY
3. Replace routes/routes.js:48:
accessKey: conf.UNSPLASH_ACCESS_KEY,
(conf is already required at the top of routes.js — same module that supplies conf.OPENAI_API_KEY on line 40.) 4. Add a startup guard so a missing key fails fast rather than silently issuing keyless 401s: if (!conf.UNSPLASH_ACCESS_KEY) throw new Error('UNSPLASH_ACCESS_KEY not set');

F-3: No middlewares/auth.js (HIGH)

Unlike someli-api and Someli-admin-api, this repo has no shared auth middleware (confirmed: there is no middlewares/ directory in the repo). Every handler is responsible for its own auth check. New endpoints added by a future engineer who forgets are publicly callable.

Fix: 1. Create middlewares/auth.js. The existing scheme stores auth_token = 'Bearer <sid>' in tMember (see /webauthenticate, routes/routes.js:205), so validate that token:

const mysql = require("sync-mysql");
const conf = require("../conf");
const con = new mysql({ host: conf.host, user: conf.user, password: conf.password, database: conf.database, port: conf.dbPort });
module.exports = function auth(req, res, next) {
  const header = req.headers.authorization || "";
  if (!header.startsWith("Bearer ")) return res.status(401).send({ status: false, code: 401, message: "Unauthorized" });
  const rows = con.query("SELECT id FROM tMember WHERE auth_token = ? AND isDeleted = 0", [header]);
  if (!rows || !rows[0]) return res.status(401).send({ status: false, code: 401, message: "Unauthorized" });
  req.user = rows[0];
  next();
};
2. In routes/routes.js, mount it on the router with an explicit allowlist for the genuinely-public endpoints (login + health):
const auth = require("../middlewares/auth");
const PUBLIC = new Set(["/webauthenticate", "/favicon.ico"]);
router.use((req, res, next) => PUBLIC.has(req.path) ? next() : auth(req, res, next));
3. Add COPY middlewares/ ./middlewares/ to the Dockerfile (after line 44) so the new folder ships in the image. 4. Audit each existing endpoint to remove now-redundant inline checks once the global guard is in place.

F-4: CORS wildcard + malformed credentials header (MEDIUM)

server.js:8-15:

app.use(cors());
app.use((req, res, next) => {
    res.header('Access-Control-Allow-Origin', '*');
    res.header('"Access-Control-Allow-Credentials" : true');
    next();
});

Two issues: 1. Wildcard origin (both via cors() and the manual header) — every origin can call 2. The second res.header(...) call has the entire string "Access-Control-Allow-Credentials" : true as the header name. Browsers ignore. Probably intended 'Access-Control-Allow-Credentials' (name), true (value).

Fix: 1. Add the allowed origins to .env (e.g. CORS_ORIGINS=https://designer.someli.ai,https://designer-dev.someli.ai) and to conf.js as CORS_ORIGINS: process.env.CORS_ORIGINS. 2. Replace the manual header block (server.js:8-15) with a single configured cors():

const allowlist = (conf.CORS_ORIGINS || "").split(",").map(s => s.trim()).filter(Boolean);
app.use(cors({
  origin: (origin, cb) => (!origin || allowlist.includes(origin)) ? cb(null, true) : cb(new Error("CORS blocked")),
  credentials: true,
}));
3. Delete the second middleware entirely — cors({ credentials: true }) emits the correct Access-Control-Allow-Credentials: true header, so the malformed manual header is no longer needed. 4. Note: a wildcard origin (*) and Access-Control-Allow-Credentials: true are mutually exclusive per the Fetch spec, so the allowlist approach is required if cookies/credentials are sent.

F-5: 50 MB body limit (MEDIUM)

server.js:17 sets bodyparser.json({ limit: '50MB' }) globally. Acceptable for upload endpoints; excessive for the /webauthenticate POST.

Fix: 1. Lower the global default in server.js:17-18 to something small:

app.use(bodyparser.json({ limit: '256kb' }));
app.use(bodyparser.urlencoded({ extended: true, limit: '256kb' }));
2. Override only on the routes that legitimately carry large Polotno JSON / image payloads, applying a per-route parser:
const bigJson = require("body-parser").json({ limit: '50mb' });
router.post("/somePolotnoEndpoint", bigJson, handler);
(Candidate endpoints are those reading req.body.polotno, e.g. around routes/routes.js:1248, 1265, 3743.) 3. File uploads already go through express-fileupload (server.js:16), not the JSON parser — set its cap explicitly: app.use(fileUpload({ limits: { fileSize: 50 * 1024 * 1024 } })).

F-6: conf/credentials.json committed — Google OAuth client secret (HIGH)

Confirmed committed: git ls-files conf/credentials.json returns the path, and it is not covered by .gitignore. It is a Google OAuth web-client config containing a live client_secret:

{ "web": { "client_id": "305153224854-…apps.googleusercontent.com",
           "client_secret": "drGPpS_h7PCgekNpN5P3Fr5c",
           "token_uri": "https://oauth2.googleapis.com/token", … } }

Fix: 1. Rotate now: Google Cloud Console → APIs & Services → Credentials → open this OAuth client → Reset secret (or delete and recreate the client). The committed client_secret is compromised. 2. Stop tracking the file and ignore it:

git rm --cached conf/credentials.json
echo "conf/credentials.json" >> .gitignore
3. Provide it at runtime instead of committing it — either mount the file from a secrets store at deploy time, or store the JSON in an env var (GOOGLE_OAUTH_CREDENTIALS) and write it to disk on boot, or read the individual fields from process.env (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET). 4. The Dockerfile:46 line COPY conf/ ./conf/ will bake this secret into the image — see F-13. Once the file is removed from the repo, ensure the deploy injects the credential at runtime rather than via that COPY. 5. Scrub it from git history with git filter-repo --path conf/credentials.json --invert-paths after rotation, then force-push (coordinate with the team).

F-7: SQL injection risk in string-interpolated queries (MEDIUM)

Many con.query(\SELECT ... WHERE id = ${i.id}`)patterns (411 template-literalcon.query(`…`)calls inroutes/routes.js` alone). Most interpolated values are integers from prior queries — safe. But several interpolate request-controlled strings directly, which is exploitable:

  • routes/routes.js:1499 and :1620 — SELECT * FROM tMediaLib WHERE … searchTerm = '${req.body.text}' AND category = '${req.body.category}'
  • routes/routes.js:1580, :1703, :1810, :1845 — INSERT INTO tImageJobs (… searchedKeyword) VALUES (… '${req.body.text}') (and req.body.searchTerm, req.body.tableName)
  • routes/routes.js:1577, :1700, :1807 — Update tJobs set status=4 where id=${req.body.trowId}
  • /webauthenticate, routes/routes.js:187 — m.username='${reqObj.email}' interpolated into the login query (a pre-auth injection point)

The DB layer compounds this: modules/dbDriver/lib/mysql.js passes a raw SQL string straight to pool.query(sql, …) with no placeholder array (mysql.js:39-53), and routes.js also uses sync-mysql directly via const con = new mysql({…}) (routes.js:21,52).

Fix: 1. The repo already depends on mysql2 (package.json:31) and sync-mysql supports placeholders, so switch interpolation to parameter binding. For each request-controlled query, pass values as an array:

// routes/routes.js:1499 — before
con.query(`SELECT * FROM tMediaLib WHERE sourceUrl = '${t.urls.full}' AND searchTerm = '${req.body.text}' AND category = '${req.body.category}';`);
// after
con.query('SELECT * FROM tMediaLib WHERE sourceUrl = ? AND searchTerm = ? AND category = ?', [t.urls.full, req.body.text, req.body.category]);
// routes/routes.js:1580 — after
con.query('INSERT INTO tImageJobs (trowId, tableName, status, category, searchedKeyword) VALUES (?, ?, 0, ?, ?)', [req.body.trowId, req.body.tableName, req.body.category, req.body.text]);
// routes/routes.js:187 — after (build the login query with a placeholder)
reqObj.q = 'SELECT m.id,m.sid,m.password,role.policy FROM tMember m LEFT JOIN tRoles role ON m.role_type = role.id WHERE m.username = ? AND m.isDeleted = 0';
reqObj.params = [reqObj.email];
2. Extend the DB wrapper to forward bind parameters so callers can pass them. In modules/dbDriver/lib/mysql.js:39-53 change executeQuery(sql, cb) / query(sql, cb) to accept (sql, params, cb) and call pool.query(sql, params, …). (Migrating the pool to mysql2 gives the same ?-placeholder API plus prepared statements.) 3. For identifiers that genuinely vary (e.g. req.body.tableName), do NOT bind — placeholders can't parameterize table names. Validate against an allowlist: const TABLES = ['tJobs','tImageJobs']; if (!TABLES.includes(req.body.tableName)) return res.status(400)…. 4. Re-run the audit grep after each batch and track to zero:
grep -nE "con\.query\(\`.*\$\{req\.(body|query|params)" routes/routes.js

F-8: forEach over async work (MEDIUM, correctness)

Jobs frequently iterate with .forEach(async (row) => { ... await ... }). forEach does not await the async callbacks; the iteration continues immediately. Several outcomes:

  • Race conditions on shared state (e.g., nextSequence may be reused before the previous async row finishes)
  • Unbounded concurrency on external services (OpenAI / S3) when N rows are processed in parallel
  • The isOnProcess = false line may fire before async work completes, allowing the next cron tick to re-enter

There are 18 files using forEach(async …); sample call sites: job_content_swap_entry.js:42,90, job_check_temp_valid.js:32,78, job_check_color_with_json.js:29, job_get_image.js:304, job_16_30_schedule_template.js:54, job_schedule_variant.js:29, job_createdByName_Temp.js:40.

Fix: 1. Replace each forEach(async …) with a sequential for…of that actually awaits:

// job_get_image.js:304 — before
unsplashResponse.response.results.forEach(async (t) => { …await… });
// after
for (const t of unsplashResponse.response.results) { …await… }
2. Where bounded parallelism is desired (e.g. S3/OpenAI fan-out), batch instead of unbounded concurrency:
const pLimit = require('p-limit'); const limit = pLimit(5);
await Promise.all(rows.map(r => limit(() => process(r))));
3. Ensure the re-entrancy guard is reset only after all work resolves — move isOnProcess = false after the for…of loop completes (inside a finally), not inside/before it. 4. Apply across all 18 files (grep forEach(async to enumerate); the industry-clone jobs share the boilerplate, so fix the template pattern and propagate.

F-9: Polotno license key hardcoded (LOW-MEDIUM)

Confirmed hardcoded in routes/routes.js in 11 places — createInstance({ key: 'FXZvloSJvAe09-bdR9iC' }) at lines 1261, 2789, 2869, 3760, 3840, 3929, 5891, 8186, 9179, 13136 (and one commented-out at 8350). The same key recurs across the job scripts (job_get_image.js, job_schedule_template.js, etc., which all require('polotno-node')).

Fix: 1. Add POLOTNO_KEY to .env and to conf.js (after line 25): POLOTNO_KEY: process.env.POLOTNO_KEY. 2. Replace every literal occurrence with the env value:

const instance = await createInstance({ key: conf.POLOTNO_KEY });
# mechanical replacement across the repo (review the diff before committing)
grep -rl "FXZvloSJvAe09-bdR9iC" --include="*.js" .
sed -i "s/key: 'FXZvloSJvAe09-bdR9iC'/key: conf.POLOTNO_KEY/g" routes/routes.js
Ensure each touched file requires conf (the job files already do). 3. Rotate the key only if your Polotno plan treats it as a secret (some Polotno SDK keys are domain-scoped/public). If it gates billable rendering, rotate via the Polotno dashboard and treat as a secret.

F-10: No request-rate limiting (MEDIUM)

server.js doesn't install any rate-limiting middleware (no express-rate-limit in package.json). The /webauthenticate endpoint (routes/routes.js:171) is unauthenticated and not rate-limited, enabling credential stuffing.

Fix: 1. Install the middleware (it is not currently a dependency):

cd designer-api && yarn add express-rate-limit
2. In server.js (after the cors setup, ~line 16), add a tight limiter for the auth route and a looser global one:
const rateLimit = require("express-rate-limit");
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false });
app.use("/webauthenticate", authLimiter);
app.use(rateLimit({ windowMs: 60 * 1000, max: 120 }));
3. Because nginx fronts the app (Dockerfile:36), set app.set('trust proxy', 1) so the limiter keys on the real client IP from X-Forwarded-For rather than the proxy IP. 4. Mount the /webauthenticate limiter before the route is registered in routes/routes.js if you prefer route-local placement instead of server.js.

F-11: No HTTPS enforcement / security headers at app layer (LOW)

server.js sets no security headers — no HSTS, no X-Content-Type-Options, no HTTP→HTTPS redirect, and helmet is not a dependency. TLS termination is likely handled by nginx upstream (Dockerfile:36), but the app sets nothing as defense-in-depth.

Fix: 1. Add helmet for baseline headers (it is not currently installed):

cd designer-api && yarn add helmet
// server.js — before app.use(cors())
const helmet = require("helmet");
app.use(helmet({ hsts: { maxAge: 31536000, includeSubDomains: true } }));
2. If TLS is terminated at nginx, confirm nginx adds Strict-Transport-Security and redirects :80 → :443 (the container also listens on plain HTTP — Dockerfile:53 exposes 80/8080). Track the nginx side in F-12. 3. With helmet enabled, review its default Content-Security-Policy against the API's needs (this is a JSON API, so the restrictive default is usually fine).

F-12: nginx.conf in repo not audited (LOW)

nginx.conf is present at the repo root and committed; it ships into the image via Dockerfile:36 (COPY nginx.conf /etc/nginx/nginx.conf). Its contents are not reviewed in this audit. Audit for: - Correct upstream config (proxy_pass) - HSTS headers - Sensible buffer / body-size limits - Server header suppression

Fix: 1. Review the committed nginx.conf and confirm these directives are present in the relevant server/location blocks:

server_tokens off;                                   # suppress version banner
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
client_max_body_size 50m;                            # match the upload route cap (F-5)
proxy_pass http://127.0.0.1:5002;                    # confirm upstream port matches conf.port default
2. If TLS is terminated here, add a :80 → :443 redirect server block; if TLS is terminated upstream (load balancer), document that and trust X-Forwarded-Proto. 3. Keep client_max_body_size aligned with the app body limits in F-5 so nginx and Express agree (mismatch causes confusing 413s). 4. Re-run a config check before deploy: nginx -t -c /etc/nginx/nginx.conf.

F-13: .env baked into the Docker image (HIGH)

Dockerfile:35:

COPY .env conf.js favicon.ico ./
The runtime stage copies .env into the image layer, so every secret (OPENAI_API_KEY, AWS_ACCESS_KEY/AWS_SECRET_ACCESS_KEY, SENDGRID_API_KEY, DB password, PEXELS_API_KEY, PIXABAY_API_KEY, etc. — see conf.js:11-25) is readable by anyone who can pull the image (docker history / extracting a layer). .dockerignore only excludes node_modules, so it does not stop this.

Fix: 1. Remove .env from the COPY in Dockerfile:35:

COPY conf.js favicon.ico ./
conf.js already reads from process.env via dotenv (conf.js:1), so it works fine without a baked file. 2. Inject secrets at runtime instead of build time, via one of: - Env vars at run: docker run --env-file ./.env …, or per-key -e OPENAI_API_KEY=… (the .env file stays on the host / in CI secrets, never in the image). - Secrets manager: pull from AWS Secrets Manager / SSM Parameter Store at container start and export to the environment before node server.js. - Orchestrator secrets: ECS task-definition secrets, or Docker/K8s secrets mounted as env. 3. Add .env to .dockerignore so an accidental future COPY . . can't reintroduce it:
node_modules
.env
.env.*
4. Rotate every secret currently in .env — they must be assumed leaked once an image with them was built/pushed: DB credentials, AWS keys, OpenAI/SendGrid/Pexels/Pixabay keys, plus the Slack (F-1), Unsplash (F-2), Google (F-6), and Polotno (F-9) values. 5. Verify no secret remains in any layer of an existing image: docker history --no-trunc <image> and docker save <image> | tar -tf - then grep extracted layers for .env.

Outcomes-based

  • OWASP A02 / A05: hardcoded credentials (Slack F-1, Unsplash F-2, Polotno F-9, Google OAuth client secret F-6) and .env secrets baked into the Docker image (F-13)
  • OWASP A04 Insecure Design: no shared auth middleware
  • OWASP A03 Injection: string-interpolated SQL in some places
  • OWASP A05 Security Misconfiguration: CORS wildcard + malformed Access-Control header; no rate limiting
  • OWASP A07 Identification & Authentication Failures: no rate limiting on /webauthenticate

Recommendations (phased)

Phase 0a (this week)

  • Rotate Slack token (F-1)
  • Rotate Unsplash key (F-2)
  • Rotate Google OAuth client secret + untrack conf/credentials.json (F-6, confirmed committed)
  • Move Polotno license to env (F-9, confirmed hardcoded in 11 places)
  • Remove .env from Dockerfile COPY and rotate all baked-in secrets (F-13)

Phase 0 (months 0-3)

  • Add middlewares/auth.js (F-3)
  • Fix CORS configuration (F-4)
  • Add express-rate-limit (F-10)
  • Audit and fix SQL-injection risks (F-7)
  • Replace forEach over async with for...of await (F-8)

Phase 1 (months 3-9)

  • Add helmet / app-layer security headers (F-11)
  • Audit nginx.conf (F-12)
  • Tighten body-size limits per route (F-5)
  • Confirm HTTPS enforcement / HSTS at the proxy (F-11)
  • Standardise on the same auth model as Someli-admin-api / someli-api