Skip to content

Security

Findings

F-1: Token in localStorage (MEDIUM)

localStorage["auth_token"] is XSS-readable. The token has no expiry server-side and is not revokable in a durable way (in-memory revocation set in Someli-admin-api). One successful XSS → long-lived account takeover.

Mitigations: - Migrate to httpOnly; Secure; SameSite=Strict cookie (BE + FE coordinated change) - Encode exp claim in the token; FE refresh on expiry - DB-backed revocation in Someli-admin-api

Fix (FE side; the durable win requires the coordinated BE change noted in step 4): 1. Stop reading/writing the token through localStorage. The three accessors live in src/context/AuthContext.tsx:32-34:

const getAuthToken = (): string | null => localStorage.getItem("auth_token");
const setAuthToken = (token: string): void => localStorage.setItem("auth_token", token);
const removeAuthToken = (): void => localStorage.removeItem("auth_token");
plus the duplicate reader in src/services/api.ts:16-18. 2. Preferred end-state: have Someli-admin-api set the credential as an httpOnly; Secure; SameSite=Strict cookie on /authenticate, and send requests with fetch(..., { credentials: "include" }) in src/services/api.ts:60. Once on cookies, the FE no longer holds the token in JS at all, so getAuthToken/setAuthToken and the Token header at src/services/api.ts:34 are removed. 3. Until the BE supports cookies, narrow the blast radius: keep the value in an in-memory module variable (React context state — token is already tracked in AuthContext) and only fall back to sessionStorage (cleared on tab close) rather than localStorage. This does not stop XSS exfiltration but shortens the credential lifetime. 4. Coordinate with Someli-admin-api to add an exp claim and DB-backed revocation so a stolen token cannot be replayed indefinitely (see the in-memory-revocation note above).

F-2: Hardcoded Apptype (LOW)

src/services/api.ts createAuthHeaders() hardcodes:

"Apptype": btoa("admin-console"),
even though ENV.APP_TYPE exists in src/config/env.ts. The hardcoded value works (BE doesn't currently branch on Apptype), but the per-env config is dead code.

Fix: 1. In src/services/api.ts:23, replace the literal with the env-driven value:

"Apptype": btoa(ENV.APP_TYPE),
ENV.APP_TYPE is already exported from src/config/env.ts:8 (import.meta.env.VITE_APP_TYPE). 2. Ensure VITE_APP_TYPE is set in every environment's .env (it is VITE_-prefixed, so it is meant to ship to the client — confirm the value is not a secret; it is just an app-type discriminator). 3. Add a guard so a missing value fails loudly instead of silently encoding undefined, e.g. in src/config/env.ts throw at module load if APP_TYPE is empty.

F-3: 3rd-party script in production HTML (MEDIUM)

index.html:

<!-- IMPORTANT: DO NOT REMOVE THIS SCRIPT TAG OR THIS VERY COMMENT! -->
<script src="https://cdn.gpteng.co/gptengineer.js" type="module"></script>

This is the Lovable.dev runtime tag (Lovable.dev is an AI-assisted no-code editor; this codebase was bootstrapped from it). The comment instructs not to remove it.

The script is loaded in production (since it's in index.html, not behind a mode === 'development' check). That means: 1. Every page load fetches a script from cdn.gpteng.co (third-party domain) 2. If that CDN is compromised, attackers can execute arbitrary JS in admin staff's browsers 3. No Subresource Integrity (integrity="sha384-...") protects against tampering

Fix (the tag is at index.html:14-15): 1. Preferred: if Lovable.dev is no longer used to edit this codebase, delete both the comment and the <script> at index.html:14-15. There is no runtime dependency on gptengineer.js in src/ (the editor injects it only for in-Lovable editing). 2. If you cannot remove it from the source file but want it gone from production, keep it out of the shipped build with a Vite HTML transform plugin in vite.config.ts that strips the tag when mode !== 'development':

function stripLovableScript(): import("vite").Plugin {
  return {
    name: "strip-lovable-script",
    transformIndexHtml(html, ctx) {
      return ctx.server ? html
        : html.replace(/<script src="https:\/\/cdn\.gpteng\.co\/gptengineer\.js"[^>]*><\/script>/, "");
    },
  };
}
and add it alongside componentTagger() in the plugins array (vite.config.ts:23-26), gated the same way (mode === 'development' ? null : stripLovableScript()). 3. If it must remain in production, pin it with Subresource Integrity. Compute the hash once (curl -s https://cdn.gpteng.co/gptengineer.js | openssl dgst -sha384 -binary | openssl base64 -A) and add the attributes at index.html:15:
<script src="https://cdn.gpteng.co/gptengineer.js" type="module"
        integrity="sha384-<computed-hash>" crossorigin="anonymous"></script>
Note: an integrity pin breaks if Lovable rotates the file; treat option 1 as the real fix.

F-4: No CSP (MEDIUM)

Vite's default build does not set CSP headers. Whether CSP is set depends on the hosting layer (nginx?). At audit time, there is no in-repo CSP config (no vercel.json, no netlify.toml, no _headers file).

Recommendation: configure CSP at the nginx layer or via a meta tag. Minimum: default-src 'self'; img-src 'self' data: https:; script-src 'self' 'unsafe-inline' https://cdn.gpteng.co; style-src 'self' 'unsafe-inline'; connect-src 'self' <api-host>.

Fix: 1. Prefer a response header at the hosting layer (nginx) over a meta tag — headers cover frame-ancestors/report-uri, which meta cannot. In the admin server block add:

add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; script-src 'self' https://cdn.gpteng.co; style-src 'self' 'unsafe-inline'; connect-src 'self' https://<admin-api-host>; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
Drop https://cdn.gpteng.co from script-src once F-3 removes the tag. Note the chart component uses dangerouslySetInnerHTML for an inline <style> (F-9, src/components/ui/chart.tsx:79), so style-src 'unsafe-inline' is currently required; tightening it to a nonce/hash is a follow-up. 2. If you cannot reach the nginx layer, add a build-injected meta tag. Add to index.html <head> (around index.html:9):
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://<admin-api-host>; object-src 'none'; base-uri 'self'" />
3. Roll out in report-only first (Content-Security-Policy-Report-Only) to surface violations from Recharts/Radix inline styles before enforcing. 4. Commit the chosen CSP into the repo so it is not lost on redeploy: either the nginx snippet, or — if the host reads them — a static public/_headers file (it is copied verbatim into dist/). At audit time none of these exist in-repo.

F-5: No SRI (LOW)

No external scripts use Subresource Integrity. Only one external script exists (cdn.gpteng.co) — see F-3.

Fix: 1. Resolve via F-3 step 1 (remove the only external script) — once removed, there is nothing left to pin and SRI becomes moot. 2. If the script stays, apply F-3 step 3 (integrity="sha384-..." + crossorigin="anonymous" at index.html:15). 3. Add a lint guard so future external <script>/<link> tags in index.html are not merged without integrity. There is no first-party HTML linter wired up; a lightweight option is a CI grep step that fails if index.html contains an https:// <script src= without an adjacent integrity= attribute.

F-6: HMR overlay disabled (LOW dev-only)

vite.config.ts:

hmr: { overlay: false }

This disables Vite's error overlay in dev. It can hide errors during development. Re-enabling is one-line. Not a production issue.

Fix: 1. In vite.config.ts:18-21, re-enable the overlay so dev-time errors are visible:

hmr: {
  overlay: true,
}
Or delete the hmr block entirely to fall back to Vite's default (overlay on).

F-7: lovable-tagger dev plugin (LOW)

lovable-tagger is a dev-only Vite plugin (mode === 'development' && componentTagger() in vite.config.ts). It is not loaded in production builds. As long as that conditional remains correct, no production impact.

Fix (hardening / hygiene — no active vulnerability): 1. Keep the mode === 'development' gate at vite.config.ts:25; the .filter(Boolean) at vite.config.ts:26 correctly drops the false entry in production. Do not change this to an unconditional componentTagger(). 2. Verify it never ships: after npm run build, confirm the tagger's data attributes are absent from dist/ (e.g. grep -r "data-lovable" dist/ returns nothing). Add this grep as a CI assertion if you want a regression guard. 3. If Lovable.dev is fully retired (see F-3), move lovable-tagger out of the build entirely: remove the import at vite.config.ts:4, the plugin entry at vite.config.ts:25, and uninstall the devDependency.

F-8: fs.deny is minimal (LOW dev-only)

fs: { strict: true, deny: ['.env', '.git'] }

The deny list is small. In dev mode, files outside the project root could potentially be served. Vite's fs.strict mode mitigates this, but the team should periodically verify what Vite serves in dev to staff laptops.

Fix (dev-server hardening; the dev server should never be public): 1. Broaden the deny list at vite.config.ts:14-17 to cover env variants and key material:

fs: {
  strict: true,
  deny: ['.env', '.env.*', '*.pem', '*.key', '.git', '.git/**'],
}
Keep strict: true — it already confines serving to the workspace root and the explicit allow list. 2. Do not expose the dev server beyond localhost. The current server.host: "::" (vite.config.ts:9) binds all interfaces and allowedHosts includes admin.someli.ai (vite.config.ts:11-13); ensure that host is only reachable on trusted networks, or bind to 127.0.0.1 for routine local work. 3. Periodically smoke-test: with the dev server running, confirm requests like /@fs/etc/passwd are rejected.

F-9: No DOMPurify / sanitisation for dangerouslySetInnerHTML (LOW — only if used)

grep -rE "dangerouslySetInnerHTML" src/ — if any uses exist (none observed in spot-checks), they should sanitise input. If user-generated content is rendered as HTML anywhere, add DOMPurify.

On re-verification, one use does exist: src/components/ui/chart.tsx:79. It is the upstream shadcn/ui ChartStyle component, which injects an inline <style> block built from the chart's config object (theme/color CSS variables) — not from user-supplied or API-returned strings. As shipped, this is not an injection sink. The action items below keep it that way and cover any future uses.

Fix: 1. Leave src/components/ui/chart.tsx:79 as-is only while the config passed to ChartContainer stays developer-defined. If a chart's labels/colors ever come from API data or user input, the interpolated --color-${key}: ${color} could carry a CSS-injection payload — in that case validate color against a strict pattern (e.g. /^#[0-9a-fA-F]{3,8}$|^[a-z-]+$/) before interpolation, and constrain key to [a-zA-Z0-9_-]. 2. For any HTML rendering (none today), install and use DOMPurify rather than raw dangerouslySetInnerHTML:

npm install dompurify && npm install -D @types/dompurify
import DOMPurify from "dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
3. Add an eslint guard so new unsanitized uses are caught in review — enable react/no-danger (via eslint-plugin-react) in eslint.config.js, set to "warn", with the known chart.tsx line either annotated // eslint-disable-next-line react/no-danger or excluded as upstream UI code.

F-10: localStorage["user_email"] (LOW)

Storing the user's email in localStorage is a minor PII exposure (XSS-readable, same as the token). Low impact because the user knows their own email; but if accumulating PII in localStorage becomes a pattern, that's a concern.

The pattern is already spreading beyond email: user_email is written in src/context/AuthContext.tsx:67,161,186 and read in src/components/layout/Sidebar.tsx:180,191 and src/pages/MyProfile.tsx:259,493; useid/user_first_name are similarly persisted (src/pages/Accounts.tsx:534-535, src/context/AuthContext.tsx:64,154,183).

Fix: 1. Source these values from the auth context (the user object already held in AuthContext) instead of re-reading localStorage. For example, Sidebar.tsx:180,191 should use user?.email from useAuth() rather than localStorage.getItem('user_email'). 2. If a cross-reload cache is genuinely needed, keep it in sessionStorage (cleared on tab close) rather than localStorage, and store only the minimum (an opaque user id, not email). 3. When F-1 moves auth to an httpOnly cookie, fetch profile fields (email, id, name) from a /me-style endpoint on load instead of persisting them client-side at all — then useid/user_email/user_first_name no longer need to live in browser storage. 4. Clear all of these keys on logout. logout() in src/context/AuthContext.tsx:206 currently only calls removeAuthToken(); also remove useid, user_email, and user_first_name (or call localStorage.clear() for app-owned keys) so PII does not survive a logout on a shared machine.

F-11: No 401 → logout (MEDIUM)

api.ts's request() doesn't differentiate 401 from other errors. A revoked token causes every action to fail with a toast, but the user remains "logged in" client-side. Add a 401 handler.

Fix: 1. Detect the status before the body is discarded. handleResponse at src/services/api.ts:5-13 throws a plain Error that loses the status code. Branch on response.status there:

const handleResponse = async (response: Response) => {
  if (response.status === 401 || response.status === 403) {
    handleUnauthorized();           // see step 2
    throw new Error("Session expired. Please sign in again.");
  }
  if (!response.ok) {
    const error = await response.json().catch(() => ({}));
    throw new Error(error.message || response.statusText || "Something went wrong");
  }
  return response.json();
};
2. api.ts is outside React, so it cannot call the logout() hook directly. Add a small module-level callback that AuthContext registers on mount, then invoke it on 401:
// api.ts
let onUnauthorized: () => void = () => {};
export const setUnauthorizedHandler = (fn: () => void) => { onUnauthorized = fn; };
const handleUnauthorized = () => onUnauthorized();
In src/context/AuthContext.tsx, register it in an effect: useEffect(() => setUnauthorizedHandler(logout), []) so a 401 clears state and routes to /login via the existing logout() at src/context/AuthContext.tsx:206. 3. Optionally suppress the generic destructive toast (src/services/api.ts:64-70) for the 401 path so the user sees a single "session expired" message and the redirect, not a per-request error spam.

Outcomes-based summary

  • OWASP A02 Cryptographic Failures: token in localStorage, no expiry → effectively long-lived credentials
  • OWASP A05 Security Misconfiguration: no CSP, no SRI, 3rd-party script without integrity, HMR-overlay-off, three lockfiles producing inconsistent installs
  • OWASP A06 Vulnerable & Outdated Components: dependency tree is modern (good)
  • OWASP A07 Identification & Authentication Failures: weak logout (client-side only); no MFA; no session expiry; no 401 handler

What's not a finding

  • CSRF: not exploitable (token in custom header, not cookie)
  • Clickjacking: depends on nginx X-Frame-Options / frame-ancestors — not in repo, verify at hosting layer
  • Mixed content: builds use relative paths; no http:// URLs observed
  • SQL injection: not applicable (FE)
  • Hardcoded secrets: none in the FE (all auth secrets are server-side)