someli-api — code inspection¶
Audit date: 2026-05-17. Lens: concrete code-level issues not already covered by someli-doc/audit/someli-api/security.md and friends.
Summary¶
19 findings: 4 Critical, 4 High, 7 Medium, 4 Low. Three recurring themes dominate: (1) the main routes/routes.js mounts hundreds of endpoints on the raw Express app without any auth middleware, making most of them unauthenticated; (2) string-interpolated SQL throughout — several instances are directly exploitable, not merely theoretical; (3) m.password (bcrypt hash) is selected and forwarded in multiple list/search responses. Individual critical bugs include a plaintext password comparison that bypasses bcrypt on the login fallback path, a ReferenceError on every call to /RemoveSubject, and a permanent job-lock in the Facebook publish cron.
Critical¶
C1. Routes registered in apiRoutes.prototype.init have no auth middleware¶
- File:
routes/routes.js:237-242(init),server.js:171-174(mount point) - What:
apiRoutes.prototype.initassignsrouter = self.app(the raw Express app, not a sub-router). There is norouter.use(auth)call anywhere insideinit. The only route with a guard isGET /which usesmethods.ensureToken. The 700+ other endpoints registered in this file — including/getAllMemberslist,/getAllConversationlist,/searchAccountUser,/searchAllMembers,/getUserdetail:userid,/getMemberschatMessages,/bni-corporate-profile,/confirmationDetails, and all social-link and AI endpoints — are accessible to unauthenticated callers. By contrast,routes/auth.jsandroutes/paddle.jsboth open withrouter.use(auth). - Why it matters: Any unauthenticated caller can enumerate all members (
GET /getAllMemberslistreturns the entiretMembertable), read all conversations (GET /getAllConversationlist), search users (POST /searchAllMembers), and invoke AI-generation endpoints that drive up Bedrock/Gemini cost. Them.passwordexposure findings below (C4) are exploitable through these same unauthenticated list endpoints. - Fix: Inside
apiRoutes.prototype.init, addrouter.use(require('../middlewares/auth'))immediately aftervar router = self.app— or better, convert to a proper Express Router. Also add the auth middleware to the server-level mount for the routes that intentionally must be public (login, register, webhooks, FB data deletion).
C2. Plaintext password fallback in /webauthenticate and /authenticate bypasses bcrypt¶
- File:
routes/routes.js:6603-6611(webauthenticate),routes/routes.js:6665-6673(authenticate personnel branch) - What: After
bcrypt.compare(reqObj.password, result[0].password, fn), ifcheckUseris falsy, the code falls through toelse if (reqObj.password == result[0].password)(line 6603 in/webauthenticate, line 6665 in/authenticatepersonnel branch). This compares the submitted plaintext password against the stored bcrypt hash using string equality — which always fails — but more dangerously it creates a code path that would succeed if a user were created with a stored plaintext password (possible via older code paths or direct DB writes). It also means any bcrypt error causes a silent fallthrough to this branch. - Why it matters: The intent is clear (commented-out code around lines 1596-1614 shows this was the old logic). The live code should never reach this branch with a correct hash, but it exists as a landmine: a hash/store bug elsewhere could silently grant login on plaintext match. The personnel login path at line 6665 is actively exercised.
- Fix: Remove the
else if (reqObj.password == result[0].password)branch entirely from both endpoints.
C3. SQL injection in Stripe and Paddle webhook log inserts¶
- File:
routes/routes.js:17804, 17806, 20330, 20332, 20370, 20372 - What: All four webhook handlers (
/stripe_webhooks,/paddle_sandbox_webhooks,/paddle_production_webhooks,/paddle_Admin_webhooks) log events and errors by string-interpolatingJSON.stringify(event),event.type,event.eventType, anderr.messagedirectly intocon.query(INSERT INTO ...)calls. Example at line 17804:Stripe event payloads are attacker-controlled (anyone can POST to the webhook URL; signature verification prevents legitimate bypass, but the INSERT on the failure path at line 17806 runs withcon.query(`INSERT INTO tStripe_webhooks(...) VALUES ('${event.type}', '${JSON.stringify(event)}', ...)`);event = undefined, anderr.messagecomes from the Stripe SDK which may embed attacker-supplied content from the raw request). Paddle failure path at line 20332 similarly interpolatesevent(undefined at that point) anderr.message. - Why it matters: An attacker who can cause webhook signature failure can inject SQL via the error message captured in the failure-path INSERT. The Stripe failure INSERT at line 17806 calls
JSON.stringify(event)whereeventisundefined, producing the string"undefined"— that INSERT silently writes garbage but also demonstrates the path executes pre-signature-check. - Fix: Use parameterized queries for all webhook log inserts. Replace the
INSERTcalls withcon.query('INSERT INTO tStripe_webhooks(...) VALUES (?, ?, ?, ?, ?)', [event?.type, JSON.stringify(event), ..., 'Inserted']).
C4. m.password (bcrypt hash) returned in API list and search responses¶
- File:
routes/routes.js:601(/searchAccountUserdownline sub-query),routes/auth.js:16525, 16556, 16715, 16767(member list endpoints),routes/routes.js:4172(/getUserdetail) - What: Multiple endpoints SELECT
m.passwordfromtMemberand include it in the JSON response sent to the client. Examples: routes/routes.js:601: downline query inside/searchAccountUserexplicitly selectsm.password.routes/auth.js:16525, 16556, 16767: member-list queries for admin views selectm.passwordalongside'12345' AS auth_pass.routes/routes.js:4172:/getUserdetail:useridselectsm.passworddirectly.- Why it matters: Exposing bcrypt hashes enables offline cracking. Combined with C1 (unauthenticated routes),
/searchAccountUserand/getAllMemberslistreturn passwords to any caller. Even without C1, admin endpoints should never ship password hashes to the frontend. - Fix: Remove
m.passwordfrom every SELECT that feeds an API response. If password-check is needed server-side, fetch it in a separate query not included in the response object.
High¶
H1. ReferenceError: userId used before declaration in /RemoveSubject¶
- File:
routes/routes.js:365 - What: The handler calls
checkuseraccount(userId, selaccountId)at line 365 butuserIdis never declared in that handler's scope. In theauth.jssub-router it would come fromres.userId, but inroutes.js(which usesself.appwithout an auth middleware) there is no destructure ofres.userIdbefore this call. JavaScript will throwReferenceError: userId is not definedon every request toPOST /RemoveSubject. - Why it matters: This route is completely broken — every call throws an unhandled exception that results in a 500 with no response (because the error propagates to the catch block at the
trylevel which does not have an explicit fallbackres.send). Users cannot remove subjects. - Fix: Add
const { userId } = res;at the top of the handler (consistent with other routes in the same file that use this pattern, e.g., line 1505, 1994).
H2. Hardcoded LinkedIn OAuth client secret¶
- File:
routes/routes.js:2211-2212androutes/routes.js:9052-9053 - What: The LinkedIn OAuth client secret
"Cu1nqKFcGY2PMget"is hardcoded in source at two call sites (/linkedinauthand a second OAuth exchange handler). The client ID"86j3m5i3znecqk"is also hardcoded. These are not listed insecurity.md's hardcoded-secrets table (which covers Slack, Polotno key, and session secret). - Why it matters: Any developer with read access to the repo can impersonate the Someli LinkedIn app, exchange codes, and obtain LinkedIn access tokens for any user who has authorized the app. Rotation requires a code change and redeployment.
- Fix: Move both values to
.env(LINKEDIN_CLIENT_ID,LINKEDIN_CLIENT_SECRET) and read viaconf.js.
H3. SQL injection via memberId/conversationId in handleChatMessage¶
- File:
routes/routes.js:123 - What: The SQL insert for chat messages parameterizes only
message(?) but interpolatesmemberIdandconversationIddirectly: BothmemberIdandconversationIdcome fromdata, which originates fromreq.body(see line 2168-2169). No type-casting or validation is performed before interpolation. - Why it matters: Sending
memberId = "1); DROP TABLE tMessages; --"would execute arbitrary SQL. The/getMemberschatMessagesendpoint (line 2166) is inroutes.jsand therefore currently unauthenticated (C1). - Fix: Use
?placeholders for all three values.
H4. isOnProcess lock permanently stuck on uncaught error in job_facebook_publish.js¶
- File:
job_facebook_publish.js:36-47(cron),job_facebook_publish.js:91(publish call) - What: The cron sets
isOnProcess = trueat line 39 and then callspost_cp_feeds()at line 41 without.catch(). Insidepost_cp_feeds,publish(sql)is called at line 90 as a fire-and-forget (noawait, no.catch()). Ifpublishthrows before reaching any of theisOnProcess = falseassignments, the flag staystruepermanently. The cron then skips every subsequent tick, silently halting all Facebook publishing for the lifetime of the PM2 process. - Why it matters: Any unexpected DB or network error during the publish flow (not caught in the
tryblock insiderequest(...)at line 236) freezes the job. There is no watchdog, no timeout reset, no alerting. - Fix: Wrap the entire body of
post_cp_feeds()in a try/finally that guaranteesisOnProcess = false. Similarly add.catch(err => { isOnProcess = false; console.error(err); })to thepost_cp_feeds()call site.
Medium¶
M1. SQL injection in /searchAllMembers via req.body.username¶
- File:
routes/routes.js:648 - What:
SELECT * FROM tMember WHERE username like '%${req.body.username}%'—req.body.usernameis interpolated with no escaping or parameterization. - Why it matters: A request with
username = "' OR '1'='1returns all members. Combined with C1, this endpoint is unauthenticated. - Fix: Use a parameterized
LIKE ?with the wildcard appended in application code:con.query('SELECT ... WHERE username LIKE ?', [%${req.body.username}%]).
M2. SQL injection in /searchAccountUser via search parameter¶
- File:
routes/routes.js:534 - What:
queryConditions += \AND (a.accountName LIKE '${search}%')`—searchcomes directly fromreq.body.search` with no sanitization. - Fix: Parameterize:
AND a.accountName LIKE ?with[search + '%']in the values array.
M3. PII and credentials logged to stdout in production login paths¶
- File:
routes/routes.js:6585, 6647, 1576 - What:
- Line 6585:
console.log("ress", result, checkUser, result?.[0].password, reqObj.password)— logs the bcrypt hash AND the submitted plaintext password. - Line 6647:
console.log("resp", employeeQuery, checkUser, employeeQuery?.[0].password, reqObj.password)— same for personnel login. - Line 1576:
console.log("ress", result, result?.[0].password, reqObj.mfa_code, ...)— logs password hash and MFA code. These all ship topm2 logsin production. - Why it matters: Anyone with server log access (ops team, logging aggregator, anyone who runs
pm2 logs) reads plaintext submitted passwords. This is a credential-exposure path independent of the DB. - Fix: Remove these console.log calls, or at minimum remove
passwordandmfa_codefrom the logged objects.
M4. tempLogin branch does not return after sending 403¶
- File:
routes/routes.js:6695-6709 - What: The
tempLoginbranch checksif (!verified)and callsres.send(responseObj)but does NOTreturn. Execution continues to line 6702 whereverified.userIdis accessed — this throwsTypeError: Cannot read properties of nullon every invalid token, which Express catches and sends a 500. - Why it matters: A caller with a deliberately malformed token sees a 500 instead of the intended 403, leaking that a code path errored. More importantly, the
SELECT sid FROM tMember WHERE isDeleted='' AND id='${verified.userId}'at line 6702 contains a type-coercion bug (isDeleted=''instead ofisDeleted=0) andverified.userIdis attacker-controlled on the injection path — though the immediate TypeError prevents exploitation here. - Fix: Add
returnafterres.send(responseObj)on line 6700.
M5. /desigrauthenticate performs plaintext password comparison in SQL¶
- File:
routes/routes.js:14225 - What:
conditions = \${conditions} AND m.password='${reqObj.password}'`— the password is both compared in plaintext (not hashed) and injected directly into the SQL string. This endpoint is inroutes.js` and is unauthenticated (C1). - Why it matters: Any value of
reqObj.passwordcontaining SQL metacharacters (e.g.,' OR '1'='1) authenticates as any user. Passwords stored in this table must be plaintext for this to work, indicating a separate legacy auth model with no hashing. - Fix: If this endpoint is still in use, hash the submitted password before comparison and use a parameterized query. If it's legacy/unused, delete it.
M6. sociallinkOld: tMemberAuth detail column updated with interpolated JSON containing user-supplied OAuth tokens¶
- File:
routes/routes.js:479 - What:
actions.queryAll({ q: \UPDATE tMemberAuth SET detail='${JSON.stringify(socialdata)}', ...` })—socialdatacontains user-suppliedaccess_token,name,email, and other fields fromreq.body`. Any single-quote in those fields breaks the query; a crafted value enables SQL injection into the UPDATE. - Fix: Use a parameterized UPDATE with
?placeholders.
M7. paddle.js /subscriptionList: Paddle customer_id values interpolated into SQL IN() clause¶
- File:
routes/paddle.js:45-50 - What:
customer_idvalues fetched from the Paddle API are string-interpolated into an SQLIN()clause: Paddle customer IDs are typically alphanumeric, but any change in Paddle's ID format or a compromised upstream response could inject SQL. The data originates from a third-party API with no validation layer. - Fix: Use
?placeholders with an array:WHERE b.customer_id IN (${customerIds.map(() => '?').join(',')})and pass the array as values.
Low / nits¶
L1. parseSignedRequest declared twice — inner version with stronger validation shadowed¶
- File:
routes/routes.js:261(insideinit, proper HMAC-SHA256 algorithm check +timingSafeEqual) androutes/routes.js:343(file scope, uses.equals()instead oftimingSafeEqual, no algorithm check) - What:
fb_data_deletionat line 318 callsparseSignedRequest— at that point in the closure, the inner function (line 261) is what's in scope. But the outer declaration at line 343 is dead code for any callers in the module scope. The inner version is the safer one; the outer is the weaker one. This is confusing but not immediately exploitable because the inner version takes precedence withininit. - Fix: Delete the outer
parseSignedRequestat line 343.
L2. downloadimages uses forEach(async ...) — async callbacks not awaited¶
- File:
routes/routes.js:145-186 - What:
codeChild.forEach(async (c, index) => { ... })—forEachdoes not await async callbacks. The function returnsdefaultCodejsonbefore any of the image-resolvingawaitcalls (e.g., the'Image Library'case at line 167 usesawait con.query(...)) complete. The returned JSON will contain unresolved/original image references rather than the resolved S3 URLs. - Fix: Replace
forEachwithfor...ofloop orawait Promise.all(codeChild.map(async ...)).
L3. Unbounded SELECT * on high-cardinality tables¶
- File:
routes/routes.js:487(SELECT * FROM tMember WHERE isDeleted = 0),routes/routes.js:682(SELECT * FROM tConversation WHERE isDeleted = 0) - What: Both queries have no LIMIT clause. As the user base grows these return the entire table into memory on every request.
- Fix: Add pagination (LIMIT/OFFSET or cursor) and select only required columns.
L4. chatSessions Map grows without bound¶
- File:
routes/routes.js:133 - What:
const chatSessions = new Map()is populated (presumably) but never pruned. There are nochatSessions.delete(...)calls visible in the file, and no TTL. Over time this leaks memory proportional to the number of chat interactions since last restart. - Fix: Either use it properly with cleanup logic, or remove it if it's dead code.
Cross-cutting observations¶
-
Two separate MySQL connection models coexist in every route file. Each of
routes.js,auth.js, andpaddle.jscreates its ownsync-mysqlconnection (con) and its ownmysql2/promisepool (connectionPromise). These are in addition to thedbconnection managed bymodules/dbDriver. The codebase has at minimum 3 separate connection objects per request-handling module, none of which share pool limits. Under load this can exhaust the MySQLmax_connectionslimit silently. -
String-interpolated SQL is the norm, not the exception. Dozens of queries throughout
routes.jsandauth.jsuse template literals with user or DB-derived values. The parameterized-query pattern (using?) is used in some newer code (e.g.,bni-corporate-profile,confirmationDetails) but has not been applied retroactively. Any future refactor should treat everycon.query(\...\${req.body...}`...)` as a potential injection site. -
async/await+ callback mixing creates invisible race conditions.bcrypt.compare(a callback-based API) is called withawaitat lines 6408, 6584, 6646 — butawait-ing a function that takes a callback returnsundefinedimmediately; the callback fires asynchronously after the route handler has already returned. The code only works becauseres.sendis called inside the callback before Express times out the response. If the callback ever fires after anotherres.sendearlier in the same request (e.g., if a preceding guard callsres.sendand the callback fires late), Node.js will throwCannot set headers after they are sent. This pattern is latent in all three login endpoints. -
Production server silently starts without routes on DB failure.
server.js:189-196deliberately starts the server with no routes if DB connection fails in production (NODE_ENV === 'production'). Health checks return 200, but all API calls return 404. This makes a DB outage at startup invisible to load-balancer health checks — traffic is routed to a broken instance.