Skip to content

`/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

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_feedback record by guessing linearIssueId.
  • Flip the status of any user_feedback record.

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.jsusercompanyid header trust + banned 'any string' fallback

usercompanyid: req.headers.usercompanyid || 'any string', // TODO remove

If 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.jspublic_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’s secret, mailgun-webhook.js’s signingKey, plus the two in lib/updates/ already removed.
  • Several Vercel crons not behind withCronAuth. /api/dev/search/reindex/* uses withInternalEndpointAuth, but /api/trigger-vehicle-* and /api/sync don’t.

Suggested PR grouping

  1. Webhook signature verification (Linear, Twilio, Mailgun, Fattura) — bundled.
  2. Cron endpoint auth (sync, trigger-vehicle-*) — bundled.
  3. Cookie injection (setCookie) — urgent, small.
  4. 3rd-party proxy auth (check-bin, localized-address-by-palace-id, sendPhoto, short-link, check-phone-unique) — bundled.
  5. Password-in-Sentry breadcrumb (create-new-client*.js) — urgent, small.
  6. Stale hardcoded secret cleanup — small.
  7. Misc hygiene: test-log removal, mobile.js TODO/fallback, error-response leakage, getRoleByToken query-string token, sign-cloudinary scoping, fattura-elettronica-api path concat.

Batch B — /pages/api/public/* (~85 files)

Auth surfaces in this directory:

  • withBrokerAuth (BROKER_API_KEY) — used uniformly across broker/*. Bearer-header check via createEnvVarAuthMiddleware. 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 encrypted companyId (and orderId/driverId/etc.). Server decrypts and proceeds. Uses AES-256-CBC without MAC: ciphertext is malleable, replayable, no nonce/expiry.
  • Hardcoded shared secretgreetings/* and cron/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:

  1. POST to input-customer-entered-phone with { requestId, customerEnteredPhone: <attacker phone> }. The handler never validates customerEnteredPhone against signatureRequest.customerPhone — it just sends an OTP to whatever phone the caller picked and overwrites the request’s verifyCode field with the new value.
  2. Receive the OTP on the attacker phone. POST it to verify-signature-request with { requestId, verifyCode }. The handler appends a new accessToken to signatureRequest.accessTokens and sets it as a cookie.
  3. POST to save-signature with the cookie + caller-controlled signatures/signature payload. The PDF is generated and stored as the signed agreement; signatureRequest.status flips to 'signed', documentUrl is 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 customerEnteredPhone and customerEnteredEmail against the signature request’s stored canonical contact before sending OTPs / overwriting verifyCode.
  • Require the accessToken cookie on decline.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 === tenantId
const 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.

pages/api/public/calendar-api/get-user-data.js
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.js is reachable by anyone (no BILLION_SECRET check) and triggers cross-tenant writes for every confirmed order belonging to the email.
  • update-order.js accepts the entire UpdateOrderInput object from the caller — mass-assignment on order fields after orders.byId(orderId).exists() succeeds. No allowlist of mutable fields.
  • unsubscribe-from-emails.js lets anyone with a leaked encrypted email unsubscribe that customer (low impact, but still no real auth).
  • updateVehiclesWithRequiredExtraServices/process.js — no verifySignature despite 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/* — uniform withBrokerAuth + 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 — Firebase verifyIdToken + exportAllowedEmails allowlist.

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’s connectionPassword and getFirebaseUserByEmail.js’s localPass (latter is a duplicate of sync.js’s secret).
  • The “decrypt-as-auth” pattern across billion/* is structurally weaker than withBrokerAuth. Its strength rests entirely on CRYPT_KEY secrecy 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)

  1. Signature-request flow hardening (B1) — validate caller-supplied phone/email against the request’s canonical contact, gate decline.js, replace 4-digit OTP. Urgent.
  2. self-check-in/save-client.js (B2) — token↔customer↔tenant binding + zod allowlist. Urgent.
  3. calendar-api/* lockdown (B3) — gate or delete. Urgent.
  4. Stale dev-secret cleanup (B4 + B7 hardcoded passwords + leftover sync.js secret) — bundle with TOP-4861’s secret-cleanup PR.
  5. Billion endpoint auth (B6) — broader rework, separate ticket.
  6. Cron orchestrator auth (B7) — bundle with batch-A’s trigger-vehicle-* cron-auth PR.
  7. Sentry breadcrumb hygiene (B8) — bundle with batch-A’s password-in-Sentry PR.
  8. Google-places proxy auth (B9) — bundle with batch-A’s 3rd-party proxy PR.
  9. 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.jsreturn_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.jsaccountId 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.tsrunJS 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 on pk_(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 via parseAccessTokens + 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.tsvalidateUploadSignatureRequest auth gate.

Not yet inspected

  • ai-image/jobs/[jobIdOrEntityType]/{[entityId],accept,reject}.ts — admin-action endpoints; assumed to follow the same pattern as generate.ts but 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 unaddressedgpt/* (no auth, OpenAI), chatgpt-translation/chatgpt-prepositive (4096-token). id-scanner.ts and ai-assistant are auth-protected. Pattern is inconsistent.
  • message_status mutations 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)

  1. Job-trigger auth (C1) — wrap jobs/{ban,unban,turnOffBillionIntegration}/start-job.js. Highest impact in batch C.
  2. GPT proxy auth (C2) — bundle with batch-A 3rd-party-proxy PR and batch-B google-places/*.
  3. Cron orchestrator auth (C3) — bundle with batch-A’s trigger-vehicle-*, sync.js, and batch-B’s unauth cron orchestrators.
  4. confirm/[type].js (C4) — small PR; require auth or remove.
  5. Stripe portal return_url allowlist (C5) — small.
  6. stripe-connect/complete-account-registration.js tenancy bind (C6) — small.
  7. Inspect ai-assistant/tools/script.ts (C7) — verify sandboxing before closing.