`/pages/api` security audit
Companion to docs/top-4861-audit.md. This file tracks findings across the
non-/api/dev routes. Organized by batch matching the audit plan: A = loose
top-level handlers, B = /api/public/*, etc.
Severity scale: Critical = remote unauth abuse / data integrity loss / secret disclosure; High = unauth cost abuse, unsafe proxy, secret adjacent; Medium = auth present but insufficient validation, info leaks.
Batch A — top-level pages/api/*.{js,ts} (36 files)
Critical
A1. setCookie.js — cookie injection / session fixation
const body = JSON.parse(req.body);Object.keys(body).forEach((key) => res.cookie(key, body[key], { maxAge: 365 * 24 * 60 * 60 * 1000 }));No auth, no allowlist. Caller picks cookie name AND value. Session cookies
(token, rootToken, userCompanyId, companyId, idToken) can all be
overwritten with attacker values for 1 year.
Only legitimate caller is components/MainContainer/LanguageSelector.js for
locale. Fix: allowlist a single permitted key (locale), reject the rest.
Diogo’s Note: I’m not sure this is critical, we seem to have a vercel redirect for this route so curl it directly doesn’t seem to do anything and we can’t POST from the web browser directly. We can probably delete and implement it directly on
A2. sync.js — unauthenticated cron + leftover hardcoded secret
No auth check. Iterates all accepted synchronizations and enqueues a QStash
job per record to /api/public/sync-vehicles. Anyone can repeatedly hit
/api/sync to fan out QStash work (cost abuse / DoS).
Also exports a stale hardcoded secret that is unused anywhere:
export const secret = 'KJNKJNKNKUIOgygydsfsdfsdfsf^67^G76GGVHYB';Fix: wrap with withCronAuth; delete the stale secret export.
A3. trigger-vehicle-purchase-check.js & trigger-vehicle-reminder-check.js — Vercel crons, no auth
Both registered as crons in vercel.json. Iterate all databases and enqueue
QStash jobs to /api/public/vehicle-{purchase,reminder}-check. The downstream
public handlers are protected by verifySignature from @upstash/qstash, but
the trigger entrypoints themselves accept any caller.
Fix: wrap with withCronAuth.
A4. linear-webhook.js — no signature verification
No HMAC check on the Linear payload. Caller controls data.id, data.body,
data.user.id, data.user.name, data.state.type. Lets anyone:
- Push attacker-chosen comments onto any
user_feedbackrecord by guessinglinearIssueId. - Flip the
statusof anyuser_feedbackrecord.
The LINEAR_COMMENT_ALLOWED_FROM_THIS_USER check compares against
data.user?.id — a value the attacker also supplies in the same body.
Fix: verify Linear’s HMAC signature header against
LINEAR_WEBHOOK_SECRET before processing.
A5. twilio-webhook.js — no signature verification
No X-Twilio-Signature validation. Caller forges SMS status updates and
corrupts billable_events and message_status records via MessageSid.
Fix: validate with the Twilio SDK’s validateRequest helper using
TWILIO_AUTH_TOKEN.
A6. fattura-elettronica-webhook.js — no signature/auth
Caller controls id_azienda, sdi_stato, id. Writes
electronicBillingStatus on any tenant’s invoice by electronicBillingId.
Cross-tenant IDOR plus billing-state corruption.
Fix: verify whatever auth Fattura supports (HMAC, IP allowlist, token).
A7. mailgun-webhook.js — hardcoded signing key
const signingKey = 'key-f6059522a75d6aa9b61504120d977440';HMAC verification compares against this hardcoded value. Source-disclosed key
defeats verification. The key- prefix matches Mailgun API key format —
needs rotation regardless.
Fix: read from MAILGUN_WEBHOOK_SIGNING_KEY env var, rotate the leaked
value at Mailgun.
A8. check-phone-unique.js — no auth, phone-number enumeration
Anyone POSTs {phone} and learns whether the number is registered in Firebase.
User enumeration vector. Pairs with phone-recovery flows for account
takeover.
Fix: require an authenticated session, or restrict to the registration flow with a CSRF/turnstile check.
A9. sendPhoto.js — no auth, paid 3rd-party proxy
No auth. Forwards arbitrary body to accurascan.com/v2/api/ocr with
ACCURASCAN_KEY. Anyone can drain the OCR quota.
Fix: require auth (cookie token) before proxying.
High
A10. check-bin.js — no auth, paid 3rd-party proxy
No auth. Proxies binlookup with BIN_LOOKUP_API_KEY. Cost abuse. Also
bin.slice(0, 8) throws 500 if bin missing.
A11. localized-address-by-palace-id.js — no auth, Google API key drain
No auth. Proxies Google Places Details with GOOGLE_API_KEY. Cost abuse on a
key shared with other features.
A12. short-link.js — no auth, open URL shortener
No auth. Anyone shortens arbitrary URLs → spam reputation hit on TopRent
short-domain. Possible SSRF if shortLink() fetches the target.
A13. create-new-client.js & create-new-client-v2.js — plaintext password leaked to Sentry
Sentry.addBreadcrumb({ data: { email, password, companyIdUid, companyName, name, surname, phone },});Signup passwords land in Sentry retention. Anyone with Sentry access (or a later Sentry breach) can read them.
create-new-client-v2.js additionally has commented-out Set-Cookie for the
auth tokens — the success path returns success but doesn’t establish a
session. Looks like WIP shipped to prod (file’s own comment says “module not
used TODO complete when tokens logic will be implemented” — yet the route
exists).
Fix: drop password from the breadcrumb; verify whether v2 is reachable
in prod and either complete or remove it.
A14. mobile.js — usercompanyid header trust + banned 'any string' fallback
usercompanyid: req.headers.usercompanyid || 'any string', // TODO removeIf any GraphQL resolver trusts the usercompanyid header for tenant scoping,
this lets the caller pick the tenant. The 'any string' fallback is a banned
pattern per CLAUDE.md.
Fix: drop the fallback; confirm GraphQL doesn’t grant scope based on this header.
A15. sign-cloudinary.js — public_id not scoped to tenant
Auth present (token + identity check), but public_id is caller-controlled
and signed with overwrite: true. Authenticated user in tenant A can sign an
upload that overwrites tenant B’s image.
Fix: prefix-validate public_id against the user’s tenant before signing.
A16. getRoleByToken.js — Fauna token in query string
const { token } = req.query;Tokens in URLs end up in Vercel access logs, browser history, Referer
headers, proxy/CDN logs. Fix: move to a header or POST body.
A17. fattura-elettronica-api.js — caller-controlled outbound path + header forwarding
fetchData(`${apiUrl}${req.body.path}`, req.method, { ...req.headers, Authorization: `Bearer ${authToken}`,}, req.body);Authenticated, but req.body.path is concatenated into the outbound URL
without parsing/validation, and req.headers (including session cookies) is
forwarded to the outbound API. Path-traversal/host-confusion-adjacent surface
plus header leak.
Fix: allowlist of permitted paths, scrub headers before forwarding.
Medium
A18. test-log.js — debug echo endpoint in production
No auth; logs and returns the request body. Adds a CORS allow for
http://localhost:3000. Low impact, but shouldn’t ship.
A19. Error-response leakage
Several handlers return raw error / fullError / error.message in 500
responses (notably login-user.js, linear-webhook.js, fattura-*,
trigger-vehicle-*). Same hygiene issue we hit in /api/dev/*.
Looks fine
set-auth.js, login-user.js (modulo error leakage), login-client.js,
change-password.js, leave-company.js, is-logged.js, logout.js,
place-from-text.ts, id-scanner.ts, check-email-unique.js,
check-partner-data.js, check-user-data.js, getCompaniesCountByEmail.js,
handle-auth.js, graphql.ts.
Cross-cutting observations
- No third-party webhook signature verification anywhere. QStash callbacks
use
verifySignature, but Linear / Twilio / Mailgun / Fattura don’t. - At least three more hardcoded secrets TOP-4851 missed:
sync.js’ssecret,mailgun-webhook.js’ssigningKey, plus the two inlib/updates/already removed. - Several Vercel crons not behind
withCronAuth./api/dev/search/reindex/*useswithInternalEndpointAuth, but/api/trigger-vehicle-*and/api/syncdon’t.
Suggested PR grouping
- Webhook signature verification (Linear, Twilio, Mailgun, Fattura) — bundled.
- Cron endpoint auth (
sync,trigger-vehicle-*) — bundled. - Cookie injection (
setCookie) — urgent, small. - 3rd-party proxy auth (
check-bin,localized-address-by-palace-id,sendPhoto,short-link,check-phone-unique) — bundled. - Password-in-Sentry breadcrumb (
create-new-client*.js) — urgent, small. - Stale hardcoded secret cleanup — small.
- Misc hygiene:
test-logremoval,mobile.jsTODO/fallback, error-response leakage,getRoleByTokenquery-string token,sign-cloudinaryscoping,fattura-elettronica-apipath concat.
Batch B — /pages/api/public/* (~85 files)
Auth surfaces in this directory:
withBrokerAuth(BROKER_API_KEY) — used uniformly acrossbroker/*. Bearer-header check viacreateEnvVarAuthMiddleware. Strong, with zod input schemas. Gold standard.verifySignature(@upstash/qstash/nextjs) — used on QStash queue handlers (sync-vehicles,vehicle-{purchase,reminder}-check,cron/synchronize-calendar,cron/send-birthday-reminder-company,cron/send-order-reminder-for-billion-customer-company,cron/open-customer,send-feedback-email,sync-root-vehicle).- Decrypt-as-auth — dominant pattern for
billion/*. Caller passes encryptedcompanyId(and orderId/driverId/etc.). Serverdecrypts and proceeds. Uses AES-256-CBC without MAC: ciphertext is malleable, replayable, no nonce/expiry. - Hardcoded shared secret —
greetings/*andcron/send-birthday-reminder.js. - No auth at all — many.
Critical
B1. Signature-request flow takeover via OTP overwrite
pages/api/public/signature-request/{input-customer-entered-email,input-customer-entered-phone,verify-signature-request,save-signature}.js.
Given any requestId (typically delivered to customers in an email/SMS link, so leaks via mail logs / referrers / browser history), an attacker can:
- POST to
input-customer-entered-phonewith{ requestId, customerEnteredPhone: <attacker phone> }. The handler never validatescustomerEnteredPhoneagainstsignatureRequest.customerPhone— it just sends an OTP to whatever phone the caller picked and overwrites the request’sverifyCodefield with the new value. - Receive the OTP on the attacker phone. POST it to
verify-signature-requestwith{ requestId, verifyCode }. The handler appends a newaccessTokentosignatureRequest.accessTokensand sets it as a cookie. - POST to
save-signaturewith the cookie + caller-controlledsignatures/signaturepayload. The PDF is generated and stored as the signed agreement;signatureRequest.statusflips to'signed',documentUrlis updated.
The whole signature flow is hijackable with knowledge of one ID.
decline.js is an even simpler variant — only requires requestId and declineReason, no token at all. Anyone can mark any signature request as declined.
verify-signature-request also uses a 4-digit Math.random() OTP (Math.floor(1000 + Math.random() * 9000)) with no rate limit. Even without the overwrite trick, brute-force is trivial.
Fix:
- Validate
customerEnteredPhoneandcustomerEnteredEmailagainst the signature request’s stored canonical contact before sending OTPs / overwritingverifyCode. - Require the
accessTokencookie ondecline.js. - Replace the 4-digit OTP with a 6-digit (or longer) crypto-random code, and add a per-request attempt counter.
B2. self-check-in/save-client.js — IDOR + mass-assignment
Auth is just “knowledge of requestId: token”, but the handler doesn’t validate that the token applies to the supplied customerId or tenantId:
const checkInRequest = await databaseGatewayService.getSelfCheckInRequestByToken(token);if (!checkInRequest) throw …// no check that checkInRequest.customerId === customerId or .tenantId === tenantIdconst scopedClient = new Client({ secret: `${ROOT_KEY}:${tenantId}:admin` });await scopedClient.query(fql` let updatedData = Object.assign(${customerData}, { birthDate }); customer.update(updatedData);`);tenantId and customer.id are caller-controlled, and customerData is the entire body object: it gets Object.assign-merged into the client record, so the caller picks both which record to write to and what fields to write.
With one valid self-check-in token (in any tenant), an attacker can rewrite arbitrary fields on any client record in any tenant.
Fix: assert checkInRequest.customerId === customerId && checkInRequest.tenantId === tenantId; allowlist mutable fields with zod.
B3. calendar-api/* — entire directory unauthenticated
get-user-data.js, get-users-calendars.js, write-results.js, get-or-create-calendar.js, insert-or-update-event.js, get-all-companies-ids.js, initialize-calendar.js — all accept companyId (and other params) from the body, then build a ${ROOT_KEY}:${companyId}:admin scoped client. No auth, no signature, no token.
const { userId, companyId, isFaunaMigrationJobsEnabled } = req.body;const faunaKey = `${ROOT_KEY}:${companyId}:admin`;const clientX = new ClientX({ secret: faunaKey });const data = await getAllData({ clientX, userId });res.status(200).json({ ...data, notCompletedJobs });Anyone on the internet can read or mutate any tenant’s calendar/user data with admin Fauna scope. initialize-calendar.js’s own comment says “this router is not used anywhere” — strong candidate for deletion.
get-all-companies-ids.js even helpfully returns every company ID (encrypted), giving an attacker an enumeration of valid companyId values to feed back into the other endpoints — though decrypt isn’t needed, since the handlers accept the plaintext companyId.
Fix: if these are internal callbacks, gate with withInternalEndpointAuth. If they’re QStash callbacks, wrap with verifySignature. If they’re dead code, delete.
B4. getFirebaseUserByEmail.js — privilege-escalation primitive
const localPass = 'KJNKJNKNKUIOgygydsfsdfsdfsf^67^G76GGVHYB';…if (localPass !== pass) { return 401; }const user = await getUserByEmail(email);await setCustomUserClaims(user.uid, { role: 'authenticated' });“Auth” is a hardcoded password. Same string as sync.js’s secret — almost certainly the original “dev secret” that was rotated everywhere except here.
Worse, the handler sets a Firebase custom claim (role: 'authenticated') on the looked-up user. Custom claims are baked into Firebase ID tokens; whatever downstream code branches on decodedToken.role === 'authenticated' (or sees its presence) gets the privilege.
With knowledge of the source-disclosed password, an attacker can grant authenticated claim to any email they choose — turning a Firebase-only account into one our backend trusts.
Fix: delete or wrap with withInternalEndpointAuth; even then, do not silently mutate custom claims as a side effect of a “lookup” call. Rotate the leaked password (and the duplicate in sync.js).
B5. test-get-minimal-distance.js, test-parallel-limits.js, make-random.js — debug endpoints in production
test-get-minimal-distance.js: no auth, Access-Control-Allow-Origin: *, caller picks tasks=N, each task does 2 paid Google Distance Matrix calls. Trivial Google-API quota drain.
test-parallel-limits.js and make-random.js: gated by another hardcoded password ('kjNjU99nJNJNAHbsb'), make-random is just Math.random() — they exist only to support each other. Should not be in prod.
Fix: delete all three.
High
B6. billion/* — replayable decrypt-as-auth pattern
Affects: post-client.js, add-driver.js, remove-driver.js, confirm-email.js, unsubscribe-from-emails.js, update-client-data.js, update-order.js, update-order-status.js, update-order-note.js, read-order.js (read-order also requires BILLION_SECRET), billion-mailgun-webhook/index.js, updateVehiclesWithRequiredExtraServices/start.js, …/process.js.
Issues:
- AES-256-CBC without MAC: ciphertexts are malleable; a captured ciphertext is a permanent bearer token for that companyId/orderId/etc.
- No nonce, no expiry.
update-client-data.jsis reachable by anyone (noBILLION_SECRETcheck) and triggers cross-tenant writes for every confirmed order belonging to the email.update-order.jsaccepts the entireUpdateOrderInputobject from the caller — mass-assignment on order fields afterorders.byId(orderId).exists()succeeds. No allowlist of mutable fields.unsubscribe-from-emails.jslets anyone with a leaked encrypted email unsubscribe that customer (low impact, but still no real auth).updateVehiclesWithRequiredExtraServices/process.js— noverifySignaturedespite being a QStash worker callback. Anyone can POST{ companyId, vehicles[] }to write to vehicles_pending.
Fix: move billion endpoints behind withBrokerAuth (or a similar shared-secret scheme with timing-safe compare) and zod-validate inputs; switch the process.js worker to verifySignature like its peers; allowlist mutable fields on update-order.js.
B7. Unauthenticated cron orchestrator entrypoints
cron/send-birthday-reminder.js, cron/retrieve-customer-from-billion-order.js, cron/synchronize-calendars.js, cron/send-order-reminder-for-billion-customer.js — all top-level cron entrypoints (the orchestrator that fans out to QStash). None validate Authorization: Bearer $CRON_SECRET. Anyone can repeatedly trigger them for cost abuse / DoS.
cron/send-birthday-reminder.js additionally exports a hardcoded connectionPassword ('KKmiUI9*8())9jNMNMmklm12#*') used by greetings/send-birthday-greeting-to-clients.js and greetings/send-birthday-remind-to-operator.js for “auth”. Source-disclosed → those greeting endpoints have effectively no auth, and an attacker can spam customer birthday emails on any tenant by passing companyId directly.
Fix: wrap orchestrators with withCronAuth; replace the connectionPassword import with withInternalEndpointAuth on the greeting endpoints.
B8. password-recovery/change-password-otp-{verify,change}.js — OTP and recovery token logged to Sentry breadcrumb
Both files do:
Sentry.addBreadcrumb({ message: 'Verify OTP', data: { otp, token, headers: req.headers } });The OTP, the recovery token, and the request headers (which include Cookie) all land in Sentry retention.
Fix: drop otp, token, and the full headers object from breadcrumbs. Log a redacted version (e.g., tokenPrefix: token.slice(0, 4)) if useful for debugging.
B9. google-places/autocomplete.ts & google-places/details.ts — unauth Google API proxies
Same pattern as place-from-text.ts (which requires auth) and localized-address-by-palace-id.js (which doesn’t). These two also have no auth — anyone can drain GOOGLE_API_KEY.
Fix: require getTokens(req).token like place-from-text.ts does, or restrict via referrer/CORS allowlist if they’re called from public booking widgets.
Looks fine
broker/*— uniformwithBrokerAuth+ zod schemas + scoped clients.- QStash queue handlers (
sync-vehicles.js,vehicle-{purchase,reminder}-check.js,send-feedback-email.js,sync-root-vehicle.ts,cron/{open-customer,send-birthday-reminder-company,send-order-reminder-for-billion-customer-company,synchronize-calendar}.js). export/companies.js&export/companies-v2.js— FirebaseverifyIdToken+exportAllowedEmailsallowlist.
Not yet inspected
Mostly read-only marketplace queries that look low-risk by name; flag if you want them looked at:
addSourceLog.js, calculate-{delivery-price{,-for-billion,-for-multiple-vehicles-for-billion},distance}.js, categories-by-vehicle-type.js, checkout-page.js, create-order-from-billion.js, cron/check-tenant-limits/* (Upstash workflow setup), get-{available-vehicles,calculation-by-id,prices-by-interval,privacy-policy,site-by-id,vehicle-by-id}.js, order-status.js, post-order.js, send-magic-link.ts, update-vehicle-in-billion.js, vehicles-list-page.js, widget/* (designed for embedding; should still be checked for tenant-scoping).
Cross-cutting observations
- Two more hardcoded secrets missed by TOP-4851 in this batch:
cron/send-birthday-reminder.js’sconnectionPasswordandgetFirebaseUserByEmail.js’slocalPass(latter is a duplicate ofsync.js’ssecret). - The “decrypt-as-auth” pattern across
billion/*is structurally weaker thanwithBrokerAuth. Its strength rests entirely onCRYPT_KEYsecrecy plus the obscurity of the encrypted IDs in transit. Since those IDs ship to customers in emails (Mailgun retains bodies), they are not durably secret. - The signature-request flow (B1) is the highest-priority finding in the whole audit so far — it gives full takeover from a single ID.
Suggested PR grouping (batch B)
- Signature-request flow hardening (B1) — validate caller-supplied phone/email against the request’s canonical contact, gate
decline.js, replace 4-digit OTP. Urgent. self-check-in/save-client.js(B2) — token↔customer↔tenant binding + zod allowlist. Urgent.calendar-api/*lockdown (B3) — gate or delete. Urgent.- Stale dev-secret cleanup (B4 + B7 hardcoded passwords + leftover
sync.jssecret) — bundle with TOP-4861’s secret-cleanup PR. - Billion endpoint auth (B6) — broader rework, separate ticket.
- Cron orchestrator auth (B7) — bundle with batch-A’s
trigger-vehicle-*cron-auth PR. - Sentry breadcrumb hygiene (B8) — bundle with batch-A’s password-in-Sentry PR.
- Google-places proxy auth (B9) — bundle with batch-A’s 3rd-party proxy PR.
- Test endpoint removal (B5) — small PR.
Batch C — /jobs, /stripe, /stripe-connect, /agreements, /ai-assistant, /ai-image, /gpt, /confirm, /export, /ff, /health, /company-signature, /upload-company-signature (53 files)
Critical
C1. jobs/{ban,unban,turnOffBillionIntegration}/start-job.js — unauthenticated platform-mutating triggers
Three trigger endpoints with no auth check at all:
jobs/banCompanyJob/start-job.js— POST{ companyId, username }→ enqueues a QStash job that bans the company: unpublishes all vehicles, deactivates all orders.jobs/unbanCompanyJob/start-job.js— same shape, unbans.jobs/turnOffBillionIntegrationJob/start-job.js— same shape, disables Billion integration for the tenant.
The downstream background-job.js workers correctly verify upstash-signature from the Receiver — but the trigger endpoints have nothing. An unauthenticated caller hits the trigger, the trigger calls qstashClient.publishJSON(...) itself, QStash signs the request, and the worker happily processes a ban/unban/disable for any company in the platform.
username is captured as a string field but never validated against any user. It’s just an audit-trail label.
Fix: wrap all three start-job.js files with withInternalEndpointAuth (or admin-only equivalent) before they enqueue work.
C2. gpt/* — unauthenticated OpenAI proxies
Four endpoints, all unauthenticated, all proxy arbitrary caller text to OpenAI with OPENAI_API_KEY:
gpt/chatgpt-generation.js(gpt-3.5-turbo, max_tokens 174)gpt/chatgpt-generation-4o.js(gpt-4o, max_tokens 174)gpt/chatgpt-prepositive.js(gpt-3.5-turbo, max_tokens 4096)gpt/chatgpt-translation.js(gpt-3.5-turbo, max_tokens 4096)
Anyone on the internet can drain the OpenAI quota indefinitely. The two 4096-token endpoints are the cheapest abuse vector per request. chatgpt-translation.js and chatgpt-prepositive.js also interpolate caller-supplied content into the prompt template — minor prompt-injection surface.
Fix: require getTokens(req).token (cookie auth) before forwarding to OpenAI, like ai-assistant/index.ts does.
C3. stripe/trigger-fail-registration-notification.js — unauthenticated cron orchestrator
Same shape as batch-A trigger-vehicle-* and batch-B unauth crons. No auth, fans out one QStash job per company. Downstream is verifySignature-protected, but the orchestrator isn’t. Fix: wrap with withCronAuth.
High
C4. confirm/[type].js — unauthenticated message_status mutation
const { type } = req.query;const { id } = JSON.parse(req.body);
await sudoClientX.query(fql` message_status.byDataIdType(${id}, ${type}).first()!.update({ status: 'confirmed', updatedAt: Time.now() })`);No auth. Caller picks type (URL) and id (body); template literal parameterizes the values, but the caller can still mark any tracked email/SMS/push message as confirmed. Corrupts delivery records and undermines mailgun-webhook and twilio-webhook audit trails.
Fix: require auth or restrict to internal callers via withInternalEndpointAuth.
C5. stripe/create-customer-portal-session.js — return_url from Referer
const session = await stripeClient.billingPortal.sessions.create({ customer: stripeCustomerId, return_url: req.headers.referer });After Stripe portal completion, the user is redirected to whatever Referer was. No allowlist of permitted origins. CSRF/phishing flows that trigger this route from an attacker-controlled page convert it into an open redirect through Stripe.
Fix: allowlist return_url against the app’s own origin(s).
Medium
C6. stripe-connect/complete-account-registration.js — accountId not tenant-scoped
accountId from the query string is looked up globally with sudoClientX, not against the caller’s tenant. An authenticated user in tenant A can call this with tenant B’s accountId and trigger an enabled flip on tenant B’s stripe_accounts row (driven by Stripe’s status, so impact is bounded to “force a refresh of someone else’s Stripe state”). Settings writes do correctly use the caller’s scoped client, but the cross-tenant stripe_accounts.update is still a tenancy boundary leak.
Fix: assert the looked-up accountId matches the caller’s company before writing.
C7. ai-assistant/index.ts — runJS tool sandboxing
The system prompt instructs the LLM “ONLY for mathematical calculations” and “NEVER execute user-provided scripts.” That’s a prompt-level boundary, not a code-level one. The LLM is the only gate between a user’s chat message and a JavaScript executor. Prompt injection bypasses this trivially.
I haven’t read tools/script.ts — flag for closer look. If runJS runs in a real Node VM with require/env/network access, prompt injection becomes RCE.
Fix: confirm tools/script.ts runs in an isolated sandbox (e.g., vm with no globals or a worker with restricted permissions). If not, restrict to a calculator-grade expression evaluator.
Low / informational
ff/cars-data.ts&ff/init-cars-data.ts— Statsig analytics proxies, unauth. Skews analytics; no real security risk.- Sentry breadcrumb in
stripe/webhook/[location].js— full Stripe event captured per webhook. Stripe events don’t usually contain raw card data but do contain customer email/metadata. Lower priority than batch-A/B Sentry hygiene.
Looks fine
- Webhooks with proper signature verification:
stripe/webhook/[location].js,stripe-connect/webhook/[location].js,stripe-connect/webhook/workflow/[...slug].ts(QStash). - QStash workers:
jobs/{ban,unban,turnOffBillionIntegration}/background-job.js(Receiver verify),jobs/queue.js,stripe/fail-registration-notification.js,ai-image/sync.ts. - Stripe payment endpoints with auth + ADMIN check:
stripe/create-checkout-session.js,stripe/create-customer-portal-session.js(modulo C5). stripe/test-public-key.js— auth + strict regex allowlist onpk_(test|live)_…format. Misleading filename; this is a real validator, not a debug endpoint.agreements/{billion,terms-of-use}.js— read-only public Terms-of-Use docs.export/audit-logs.ts— auth + ADMIN/OPERATOR role + tenant scoping + global-ID validation.export/root-vehicles.ts— auth + ADMIN +checkBillionAccess+ feature-flag gate. Most-defended endpoint in this audit.ai-assistant/index.ts— auth + feature-flag gate (modulo C7).ai-image/generate.ts— auth viaparseAccessTokens+checkBillionAdminAccess+ scoped Fauna identity.ai-image/callback.ts— provider callback that no-ops on unregistered taskIds.health/database.ts— public health check, by design.company-signature/index.ts— Bearer + Firebase verify + tenant-scoped.upload-company-signature/index.ts—validateUploadSignatureRequestauth gate.
Not yet inspected
ai-image/jobs/[jobIdOrEntityType]/{[entityId],accept,reject}.ts— admin-action endpoints; assumed to follow the same pattern asgenerate.tsbut worth verifying.ai-image/background-variants.ts— likely read-only catalog.ai-assistant/tools/script.ts— referenced by C7. Should be inspected before closing this batch.
Cross-cutting observations
- Trigger/orchestrator vs worker pattern is consistently broken — across all three batches, QStash queue handlers correctly use
verifySignature/Receiver.verify, but the entrypoints that enqueue work to those handlers usually have no auth. An attacker doesn’t need to forge a QStash signature; they call the trigger, and the trigger signs on their behalf. The system effectively trusts “anyone who can reach the trigger URL” with the privilege of the worker. - LLM cost abuse is unaddressed —
gpt/*(no auth, OpenAI),chatgpt-translation/chatgpt-prepositive(4096-token).id-scanner.tsandai-assistantare auth-protected. Pattern is inconsistent. message_statusmutations are reachable from three unauthenticated paths:mailgun-webhook.js(broken sig),twilio-webhook.js(no sig),confirm/[type].js(no auth at all). All corrupt delivery tracking.
Suggested PR grouping (batch C)
- Job-trigger auth (C1) — wrap
jobs/{ban,unban,turnOffBillionIntegration}/start-job.js. Highest impact in batch C. - GPT proxy auth (C2) — bundle with batch-A 3rd-party-proxy PR and batch-B
google-places/*. - Cron orchestrator auth (C3) — bundle with batch-A’s
trigger-vehicle-*,sync.js, and batch-B’s unauth cron orchestrators. confirm/[type].js(C4) — small PR; require auth or remove.- Stripe portal
return_urlallowlist (C5) — small. stripe-connect/complete-account-registration.jstenancy bind (C6) — small.- Inspect
ai-assistant/tools/script.ts(C7) — verify sandboxing before closing.