Skip to content

Security

Status: Audit v0.1 (2026-05-09); addendum 2026-05-22 (WC-SEC-16/17/18 — external clickjacking report triage + nginx TLS/version findings).

This document enumerates the security posture of the web client. Findings are organised roughly by OWASP Top 10 (2021) categories where applicable.

Critical findings (TL;DR)

ID Finding Severity OWASP
WC-SEC-1 AWS access keys hardcoded in plugins/aws_sdk.js committed since 2022-08-01 Critical A02 (Cryptographic Failures), A05 (Security Misconfig)
WC-AUTH-2 Session token stored in browser-readable cookies; XSS = account takeover High A07 (Identification & Authentication Failures)
WC-SEC-2 No Content-Security-Policy header; unsafe-inline scripts via __dangerouslyDisableSanitizers in nuxt.config.js High A05
WC-SEC-3 No Subresource Integrity on 14 third-party CDN scripts High A08 (Software & Data Integrity Failures)
WC-SEC-4 9 v-html use sites without sanitisation (incl. Chatbot rendering AI output) High A03 (Injection)
WC-SEC-5 Vue 2 / Nuxt 2 / Webpack 4 — all EOL; no CVE patches High A06 (Vulnerable & Outdated Components)
WC-SEC-6 No automated dependency-update pipeline (no Dependabot/Renovate) High A06
WC-AUTH-4 Frontend-trusted roleType in cookies; user-editable Medium–High A01 (Broken Access Control)
WC-SEC-7 No security headers from nginx (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy) Medium A05
WC-SEC-16 External clickjacking report (2026-05-22) — one valid kernel (framable app), rest unsubstantiated Low–Medium A05
WC-SEC-17 Deprecated TLS 1.0 / TLS 1.1 enabled in nginx.conf (PCI-DSS prohibited) Medium A02, A05
WC-SEC-18 nginx version disclosure — server_tokens off is commented out Low A05

Detail follows.

2026-05-22 update: an external party emailed a "clickjacking → account takeover" disclosure for https://app.someli.ai/#login. It is triaged in § 18 (WC-SEC-16). Net: it re-discovers the known framing gap (WC-SEC-7 / WC-SEC-13) and the verification turned up two new nginx findings (WC-SEC-17, WC-SEC-18). The report's escalation claims (XSS, CSRF, account deletion, "malware execution", "PC hijack", "takeover of all accounts") are not substantiated.

1. WC-SEC-1 — Hardcoded AWS access keys

plugins/aws_sdk.js:1-7:

const AWS = require('aws-sdk');
export const myBucket = 'someli';
export const s3 = new AWS.S3({
    accessKeyId: "AKIATWYW7A2V7KJWR6S6",
    region: 'us-west-2',
    secretAccessKey: "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"
});

Severity: Critical.

Status of the key:

  • Committed since the initial commit on 2022-08-01 (git log --oneline plugins/aws_sdk.js → 6929fe58 web_v1.1.16). Author: Suganya J.
  • In git history forever — the keys are reachable through git log even if the file is deleted today.
  • The plugin is NOT registered in nuxt.config.js → plugins and is not imported anywhere in pages/, components/, store/, etc. (grep -rln "from '@/plugins/aws_sdk'\|from '~/plugins/aws_sdk'" pages components store plugins). Webpack tree-shaking should keep it out of the production bundle.
  • Confirmed not present in current dist/_nuxt/*.js (grep -lE "AKIATWYW7A2V7KJWR6S6" dist/_nuxt/*.js → empty).

So the immediate exposure is: anyone who has had read access to this git repository at any point since 2022-08-01. This includes:

  • Any current or former engineer
  • Any contractor with repo access
  • Anyone the repo was ever shared with (e.g., cloud-IDE sessions, CI mirrors, forks)

Fix (Phase 0a, this week):

  1. Rotate the AWS access key immediately in AWS IAM. Do this before any other change — the rotation cannot wait.
  2. Audit AWS CloudTrail for this access-key-id since 2022-08-01 to identify any unauthorised use.
  3. Delete plugins/aws_sdk.js from the working tree.
  4. Run git-filter-repo or bfg to rewrite the keys out of git history (note: this is destructive history rewriting; coordinate with all clones).
  5. After rewrite, force-push and require all engineers to re-clone.
  6. Add a pre-push secret-scanner (gitleaks in CI is the standard).

Action 4 is partially mitigated by the fact that AWS will accept the rotation and the old key won't work. But the leaked credential is in many git mirrors, in the GitHub-hosted form of the repo, in any past clones — it has effectively had ~3.5 years of exposure. Treat this as a breach for compliance purposes (GDPR Article 33 if any EU customer data was reachable; SOC 2 incident response triggers).

2. WC-AUTH-2 — Token in browser-readable cookies

(Cross-referenced from authentication-client.md § Finding WC-AUTH-2.)

Severity: High.

The session token (token cookie) and full user record (usedetail cookie) are set without HttpOnly. The auth strategy uses cookie-universal-nuxt, which does not produce httpOnly cookies. JS on the same origin — including the 9 v-html sites and the dozen+ CDN scripts — can read these.

XSS = account takeover. See authentication-client.md for mitigations.

Fix (Phase 0): the durable mitigation is to stop exposing the token to same-origin JS. This needs backend coordination — the cookie has to be issued by the API, not by the SPA.

  1. Backend: have someli-api set the session cookie itself with HttpOnly; Secure; SameSite=Lax (or None only if a third-party context is required) on the /authenticate / /webauthenticate responses, instead of returning the token in the JSON body for the SPA to store. (someli-api/routes/routes.js login handlers.)
  2. Frontend auth strategy: in nuxt.config.js:285-298, the @nuxtjs/auth cookie/strategies block currently relies on cookie-universal-nuxt to write a JS-readable token cookie. Switch the strategy to tokenType: false / server-set cookie mode so the browser never writes or reads the token, and remove the manual $cookies.set('token', …) / usedetail writes (store/index.js, middleware/redirect.js, pages/index.vue, pages/socialAuth/index.vue).
  3. Send credentialed requests with axios withCredentials: true so the HttpOnly cookie rides along automatically (middleware/axios.js), and drop the explicit Authorization/token header injection that reads the cookie from JS.
  4. Stop storing the full user record in the browser-readable usedetail cookie; fetch it from an authenticated /me endpoint into Vuex on boot instead. This also removes the user-editable roleType/account_role trust problem (WC-AUTH-4).
  5. Until the backend change ships, the interim mitigation is the CSP + DOMPurify work (WC-SEC-2, WC-SEC-4) to shrink the XSS surface that can read the token.

3. WC-SEC-2 — No Content-Security-Policy

grep -nE "Content-Security-Policy" nuxt.config.js nginx.conf
# (empty)

The deployed nginx serves no CSP header; the SPA injects no <meta http-equiv="Content-Security-Policy"> either.

Consequence: any XSS in the app has unrestricted script execution. CDN scripts can do anything.

nuxt.config.js:121 also sets:

__dangerouslyDisableSanitizers: ['script']

This disables Nuxt's HTML-sanitisation of inline scripts in head.script. Combined with the inline scripts for Microsoft Clarity (nuxt.config.js:25-28), FirstPromoter (:30-39), and identity (:30-39), the app is committed to allowing inline scripts. A CSP that allows unsafe-inline is partial but better than no CSP.

Fix (Phase 0):

  1. Add a baseline CSP header on the nginx side (nginx.conf, inside each server {}/location / that serves the SPA — see WC-SEC-16's consolidated block; add_header is not inherited into a location with its own add_header), starting permissive (script-src 'self' 'unsafe-inline' <whitelisted CDNs>; frame-ancestors 'none'; base-uri 'self') and tightening over time. Enumerate the CDN hosts from the WC-SEC-3 table (js.stripe.com, cdn.paddle.com, ajax.googleapis.com, cdn.jsdelivr.net, kit.fontawesome.com, cdnjs.cloudflare.com, www.clarity.ms, cdn.firstpromoter.com, flagmeister.github.io) for script-src, and add connect-src for the API origin and analytics beacons.
  2. Move inline scripts to nonce-based CSP if/when feasible (requires per-request nonce generation, hard with nuxt generate — easier with nuxt start).
  3. Phase out unsafe-inline by moving inline analytics scripts to <script src="/analytics.js"> files.

4. WC-SEC-3 — No Subresource Integrity on CDN scripts

nuxt.config.js:23-119 declares 14 external CDN scripts (plus 4 local-static scripts and 2 inline scripts; ~20 total <script> declarations). The external CDN ones are:

URL Notes
https://www.clarity.ms/tag/... Inline-loader; downstream URL is dynamic
https://cdn.firstpromoter.com/fpr.js Affiliate tracking
https://js.stripe.com/v3/ Stripe (https-only)
https://ajax.googleapis.com/.../jquery.min.js jQuery
https://cdn.jsdelivr.net/.../popper.min.js Popper
https://cdn.jsdelivr.net/.../bootstrap.min.js Bootstrap 5.2
https://kit.fontawesome.com/... FontAwesome
https://cdn.jsdelivr.net/.../bootstrap.bundle.min.js Bootstrap 5.2 (duplicate)
https://cdn.jsdelivr.net/.../popper.min.js Popper 1.16 (older variant)
https://cdn.jsdelivr.net/.../bootstrap.bundle.min.js Bootstrap 5.0.2 (third copy)
https://flagmeister.github.io/elements.flagmeister.min.js Geo widget
https://cdnjs.cloudflare.com/.../slick.js Slick carousel
https://cdnjs.cloudflare.com/.../jquery.validate.min.js jQuery validate
https://cdn.paddle.com/paddle/v2/paddle.js Paddle

None of these have integrity="sha384-..." attributes (grep -nE "integrity:" nuxt.config.js → empty).

The local-static scripts (/owl.carousel.min.js, /common.js, /universal.tag.js, /identity.js) and the 2 inline scripts (Clarity init wrapper at line 26, FirstPromoter init at lines 30-39) bring the total to 20 <script> declarations.

If any of these CDNs is compromised (or an attacker MITMs a customer's connection), arbitrary JavaScript executes with full access to the token cookie.

Fix (Phase 0a, <1 day):

  1. For each version-pinned CDN script in nuxt.config.js:23-119 → head.script, generate the SRI hash and add integrity + crossorigin. Generate from the actual byte stream you load, e.g.:
curl -s https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js \
  | openssl dgst -sha384 -binary | openssl base64 -A

then in the Nuxt head entry:

{ src: 'https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js',
  integrity: 'sha384-<hash-from-above>', crossorigin: 'anonymous' }

(https://www.srihash.org/ is the GUI equivalent.) 2. SRI requires an immutable, versioned URL. Pin any unversioned @latest/floating URLs to an exact version first, otherwise the hash breaks on every upstream release. The duplicate Bootstrap/Popper entries (three Bootstrap copies, two Popper versions in the table) should be de-duplicated to one pinned version while you're here. 3. SRI cannot cover scripts whose served body is dynamic — Clarity's loader, FontAwesome kit.*, Stripe.js, and Paddle.js mutate or version internally. For those, rely on the CSP script-src allow-list (WC-SEC-2) to constrain origins instead, and document that they are intentionally un-SRI'd. 4. Add crossorigin="anonymous" alongside every integrity (required for the browser to enforce SRI on cross-origin scripts).

flagmeister.github.io is particularly notable: it's served from GitHub Pages, which means whoever controls the flagmeister GitHub user can replace it with malicious code. SRI is the only defence — pin it and hash it (step 1) as the highest priority of this finding; consider self-hosting the file in /static instead.

5. WC-SEC-4 — v-html without sanitisation

grep -rEn "v-html" components | wc -l
# 9 (occurrences)
grep -rln "v-html" components | wc -l
# 4 (unique component files)
grep -rEn "v-html" pages | wc -l
# 0

The 9 occurrences are spread across 4 component files:

Component file Occurrences
components/Chatbot.vue 4
components/Layout/EaMainSection.vue 2
components/Forms/EaInputSwitch.vue 1
components/Dashboard/EaGauge.vue 2

Most concerning:

components/Chatbot.vue:1421-1434:

formatMessage(text) {
  let cleanedText = text.replace(/\s*\{buttons:\s*\[(.*?)\]\}/g, '')
                        .replace(/\s*\{buttons\s*:\s*\[(.*?)\]\}/g, '')
                        .replace(/\s*\{[^}]*buttons[^}]*\}/g, '')
  return cleanedText
    .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
    .replace(/\*(.*?)\*/g, '<em>$1</em>')
    .replace(/##\s*(.*?)$/gm, '<h3 style="...">$1</h3>')
    .replace(/•\s/g, '<span style="...">• </span>')
    .replace(/\n\n/g, '<br><br>')
    .replace(/\n/g, '<br>')
    .trim()
}

The function takes server-returned chat text and produces HTML, then v-html interpolates that into the DOM. Any <script> tag, <img onerror=...>, or javascript: URL in the text is preserved. The bot output is AI-generated and could be prompt-injected to return malicious HTML. Stored XSS via the chat backend is possible.

components/Chatbot.vue:1436-1453 → formatProfileContent:

profileHtml += `<div class="profile-item"><strong>${formattedKey}:</strong> <span>${value}</span></div>`

value is interpolated raw into HTML. Same issue.

Other v-html sites (Layout/EaMainSection.vue, Dashboard/EaGauge.vue, Forms/EaInputSwitch.vue) use v-html for layout text — likely controlled values, but still vector-able if a backend response feeds them.

Fix (Phase 0):

  1. Install dompurify (~12 KB gzipped; not currently a dependency — confirmed absent from package.json): yarn add dompurify.
  2. In helpers/helper.js (the existing shared-helper module, alongside handleImageError/thousandSeparator), export a sanitiser that allows only the formatting tags the chat formatter emits:
import DOMPurify from 'dompurify'
export const safeHtml = (s) =>
  DOMPurify.sanitize(s ?? '', {
    ALLOWED_TAGS: ['strong', 'em', 'h3', 'span', 'br', 'div', 'a'],
    ALLOWED_ATTR: ['class', 'href', 'target', 'rel'],
    ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i, // blocks javascript:
  })
  1. Apply it at every sink:
  2. components/Chatbot.vue:1421-1434 (formatMessage) and :1436-1453 (formatProfileContent) — wrap the returned string in safeHtml(...), and HTML-escape the interpolated value/formattedKey before building the string so an attacker can't inject markup that survives an allow-listed tag.
  3. components/Layout/EaMainSection.vue (2), components/Forms/EaInputSwitch.vue (1), components/Dashboard/EaGauge.vue (2) — bind v-html="safeHtml(...)", or refactor to plain {{ }} interpolation where the value is text-only (preferred — removes the sink entirely).
  4. Add the ESLint rule vue/no-v-html: 'error' (from eslint-plugin-vue) to .eslintrc, with a narrowly-scoped // eslint-disable-next-line vue/no-v-html comment only on the sanitised lines, so any new v-html fails CI.

6. WC-SEC-5 — EOL framework stack

Package Status
vue@2.7.16 EOL 2023-12-31
nuxt@2.18.1 EOL 2024-06-30
webpack@^4.46.0 Unsupported (Webpack 5 stable since 2020-10)
bootstrap-vue@^2.22.0 Last release Apr 2022

Implications:

  • No CVE patches will be issued for new vulnerabilities discovered in any of these.
  • Security tools (Snyk, Dependabot) will flag them as "out of support."
  • Recruiting and retaining engineers familiar with the EOL stack is increasingly difficult.

This is Finding WC-DEP-EOL in dependencies-inventory.md. The migration path is Vue 2 → Vue 3 + Nuxt 2 → Nuxt 3, treated as a multi-month Phase 2 effort in the roadmap.

Fix (Phase 2 for the framework jump; Phase 0 for interim risk reduction):

  1. Interim (Phase 0): since the major upgrade can't land quickly, reduce the live exposure first — wire Dependabot (WC-SEC-6) and run yarn audit --groups dependencies to catch transitive CVEs in the current tree, and apply the security headers (WC-SEC-7) so an unpatched client-side bug has a smaller blast radius.
  2. Pin and inventory: record the exact installed versions (vue@2.7.16, nuxt@2.18.1, webpack@^4.46.0, bootstrap-vue@^2.22.0) and track each against its EOL date in dependencies-inventory.md.
  3. Migration prep (Phase 1→2): introduce Vue 2.7's Composition API in new components so they port to Vue 3 with minimal change; enable @vue/compat (Vue 3 migration build) on a branch to surface breaking changes incrementally.
  4. Migration (Phase 2): Nuxt 2 → Nuxt Bridge → Nuxt 3, Vue 2 → Vue 3, Webpack 4 → Vite/Webpack 5, and replace bootstrap-vue (no Vue 3 release) with a maintained alternative (e.g. BootstrapVueNext or plain Bootstrap 5 + Vue). This is the only step that fully closes WC-SEC-5.

7. WC-SEC-6 — No automated dependency-update pipeline

(Cross-referenced from dependencies-inventory.md § WC-DEP-7.)

Tool Wired?
Dependabot No (.github/dependabot.yml absent)
Renovate No
Snyk Not visible in repo
yarn audit in CI No (.github/workflows/dev_app.yml doesn't run it)

Dependencies are updated only when an engineer manually does so. Combined with the EOL stack, transitive CVE exposure is unbounded.

Recommendation (Phase 0, this month): enable Dependabot security-only auto-PRs. Even if framework upgrades can't be merged, point upgrades for transitive vulnerabilities should land automatically.

Fix (Phase 0):

  1. Commit .github/dependabot.yml (currently absent — confirmed ls .github/dependabot.yml → not found; the repo only has .github/workflows/dev_app.yml and uat_app_ecs.yml):
version: 2
updates:
  - package-ecosystem: "npm"        # covers yarn.lock too
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    # security updates are always opened regardless of the limit above
  - package-ecosystem: "npm"
    directory: "/polotno-editor"     # the editor has its own package.json/yarn.lock
    schedule:
      interval: "weekly"
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
  1. In the repo's GitHub settings, enable Dependabot security updates (Settings → Code security) so CVE fixes open PRs even when the version-update limit is reached.
  2. Add a yarn audit gate to CI (.github/workflows/dev_app.yml): run yarn audit --level high; test $? -lt 4 (yarn's exit code is a bitmask; <4 excludes high+critical) and surface the report as a build annotation rather than hard-failing at first, to avoid blocking deploys on the EOL stack's existing noise.

8. WC-SEC-7 — Missing security headers

The deployed nginx config (nginx.conf) sets none of:

  • Strict-Transport-Security (HSTS) — forces HTTPS
  • X-Frame-Options / frame-ancestors (CSP) — clickjacking
  • X-Content-Type-Options: nosniff — MIME sniffing
  • Referrer-Policy — referrer leakage
  • Permissions-Policy — feature gating

grep -nE "Content-Security-Policy|Strict-Transport-Security|X-Frame-Options|X-Content-Type-Options|Referrer-Policy|Permissions-Policy" nginx.conf → empty.

Fix (Phase 0): add the headers to nginx.conf (the deployed config, baked in via Dockerfile:139). Place them at http {} scope, or repeat inside each location (the SPA-serving location / at nginx.conf:67 and :105) — add_header is not inherited into a location that declares its own add_header.

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://js.stripe.com https://cdn.paddle.com ..." always;

Verify after deploy: curl -I https://app.someli.ai | grep -iE "strict-transport|x-frame|x-content-type|referrer-policy|permissions-policy|content-security". Only enable HSTS once HTTPS is confirmed working everywhere (it is hard to undo within max-age); consider a short max-age first, then raise it. This block is consolidated with TLS/version fixes in WC-SEC-16.

9. WC-SEC-8 — Public env vars in bundle

The frontend bundle contains values from the build-time .env. Public-by-design:

  • API_URL, APP_URL — public
  • STRIPE_PUBLIC_KEY_LIVE / STRIPE_PUBLIC_KEY_TEST — public by Stripe design
  • PADDLE_CLIENT_TOKEN / PADDLE_TEST_CLIENT_TOKEN — public by Paddle design
  • FACEBOOK_CLIENT_ID, TWITTER_CLIENT_ID, TWITTER_CUSTOMER_KEY — public OAuth client IDs
  • GTM_ID, CLARITY_PROJECT_ID — public analytics IDs
  • YOUTUBE_API_KEY — used only by api/index.js server middleware, which runs in the Nuxt Node process, not the browser. Not in the client bundle (verify via grep "YOUTUBE_API_KEY" dist/_nuxt/*.js after a fresh build). Confirm.
  • GOOGLE_API_KEY — may be in the bundle. [VERIFY-SEC-1] which scope is granted to this key.

Should not be in client bundle:

  • EXPRESS_SECRET — server-side only; [VERIFY-SEC-2] confirm not bundled.
  • TWITTER_CUSTOMER_SECRET, TWITTER_CLIENT_SECRET, FACEBOOK_CLIENT_SECRET — these are OAuth secrets which by definition must not leak to the client.

Fix (Phase 0a): verify the secrets are server-side only by grepping the production bundle:

yarn build
grep -nE "EXPRESS_SECRET|TWITTER_CUSTOMER_SECRET|TWITTER_CLIENT_SECRET|FACEBOOK_CLIENT_SECRET" .nuxt/dist/client/*.js dist/_nuxt/*.js

If any match:

  1. Refactor the consumer of that secret to live in api/ server middleware (runs in the Nuxt Node process, never shipped to the browser), exposing only a thin endpoint to the SPA.
  2. Confirm the secret is referenced via process.env.* server-side and is not placed in nuxt.config.js → publicRuntimeConfig or env (those inline into the client bundle); use privateRuntimeConfig / direct process.env in api/ instead.
  3. Rotate the leaked secret with the provider (Twitter/X, Facebook) — a value that shipped in any past bundle must be treated as compromised.
  4. Add the same grep to CI (.github/workflows/dev_app.yml) as a build-fails-on-match gate so a future regression is caught before deploy.

10. WC-SEC-9 — eval / new Function usage

grep -rEn "eval\(|new Function\(" pages components store middleware plugins helpers
# (empty)

Status: Clean. No eval / new Function usage in project code.

Fix (Phase 1 — regression guard only; nothing to remediate now): add an ESLint rule to keep it clean — no-eval and no-implied-eval from eslint:recommended, plus no-new-func. This pairs with the vue/no-v-html rule from WC-SEC-4 in the same .eslintrc. CI then fails if either pattern reappears.

11. WC-SEC-10 — Token in URL

grep -rEn "[?&]token=|window\.location\.search.*token" pages components middleware
# (review)

[VERIFY-SEC-3]: spot-check whether any flow puts the auth token in URL params (would be logged in browser history, server logs, referer headers).

Fix (Phase 1):

  1. Resolve the verify first: grep -rEn "[?&](token|access_token|sid)=" pages components middleware plugins store and review each social-auth callback (pages/socialAuth/index.vue, middleware/sociallink.js, middleware/redirect.js) — these are the flows most likely to carry a token in the URL fragment/query after an OAuth round-trip.
  2. If a token is found in a query param, change the flow to receive it in the response body or a (server-set, HttpOnly) cookie instead (ties into WC-AUTH-2), then router.replace() to strip the param from the address bar so it doesn't persist in browser history.
  3. If a token must transit the URL transiently (some OAuth providers require it), prefer the URL fragment (#) over the query string — fragments are not sent to the server and stay out of access logs and Referer headers — and clear it immediately after read.
  4. Add Referrer-Policy: strict-origin-when-cross-origin (already in the WC-SEC-7 header set) so any residual URL data is not leaked cross-origin via the referrer.

12. WC-SEC-11 — Hardcoded telemetry / license keys

Not "secrets" but values worth knowing where they live:

Identifier Value Location Severity
AWS access key + secret AKIATWYW7A2V7KJWR6S6 / jtzq0F0... plugins/aws_sdk.js:4-6 Critical (see WC-SEC-1)
Polotno license key FXZvloSJvAe09-bdR9iC polotno-editor/index.js:14, polotno-editor/customTextSection/partials/textTemplate.js:11 Low — license keys are origin-bound
Microsoft Clarity ID u1v818dob2 nuxt.config.js:26 (default if env unset) Low — public ID
GTM container ID GTM-KBZLX362 nuxt.config.js:269 (default if env unset) Low — public ID
FirstPromoter cid 27t8fy1e nuxt.config.js:37 Low — public ID
FullStory org ID o-1C5EVB-na1 plugins/fullstory.js:11 (in unwired plugin) Low — public ID
Hotjar site id 3465582 plugins/hotjar.js:30 (in unwired plugin) Low — public ID

The Polotno license is the only one that has a non-zero "leak" risk; Polotno's license check binds to allowed origins at the SDK level, but the team should verify with Polotno's license terms that committing the key publicly is allowed. [VERIFY-SEC-4]: Polotno license terms — does it tolerate public exposure of the key?

Fix (Phase 2):

  1. The AWS key is the only Critical here and is handled in WC-SEC-1 — rotate + history-rewrite, do not treat it as a "telemetry key."
  2. For the genuinely public IDs (Clarity, GTM, FirstPromoter, FullStory, Hotjar): move the hardcoded defaults out of source and read them solely from build-time env (process.env.CLARITY_PROJECT_ID, process.env.GTM_ID, …) so a forgotten .env doesn't silently fall back to a committed production ID. They are not secrets, so this is hygiene/Phase 2, not urgent.
  3. For the Polotno license key (polotno-editor/index.js:14, polotno-editor/customTextSection/partials/textTemplate.js:11): once VERIFY-SEC-4 confirms the license terms, either (a) keep it but document that it is origin-bound and intentionally public, or (b) inject it via process.env.POLOTNO_LICENSE at build time if the terms require non-disclosure. Either way, register the production origins (app.someli.ai) in the Polotno license dashboard so a leaked key cannot be reused on another domain.
  4. Remove the two unwired analytics plugins (plugins/fullstory.js, plugins/hotjar.js) if they are dead code — they carry committed IDs for no benefit (cross-ref dependencies-inventory.md).

13. WC-SEC-12 — Mixed content

grep -nE "src=[\"']http://" nuxt.config.js → none in head.script/head.link at the top of the file. All third-party scripts are HTTPS.

Recommendation: spot-check pages/, components/, and any inline <img src="http://..."> to confirm. Phase E verification.

Fix (Phase 2):

  1. Sweep for any remaining http:// references: grep -rEn "(src|href|url)\s*[=:]\s*[\"']?http://" pages components plugins assets nuxt.config.js | grep -v localhost.
  2. Rewrite any found to https:// (or protocol-relative is not sufficient on a TLS-only site — use explicit https://).
  3. Make the browser block (not just warn on) any stragglers by adding the CSP directive to the WC-SEC-7 / WC-SEC-2 header: upgrade-insecure-requests; plus block-all-mixed-content — together they upgrade or refuse passive/active mixed content so a future regression fails loudly.

14. WC-SEC-13 — Click-jacking

No X-Frame-Options / frame-ancestors (see WC-SEC-7). The app can be iframed by any site. Confirmed 2026-05-22 — no anti-framing control exists anywhere in the repo or the deployed nginx:

grep -rEni "x-frame-options|frame-ancestors" . --include="*.conf" --include="*.js" --include="*.vue" | grep -v node_modules
# (empty)

Realistic impact (this is a SPA, so be precise):

Scenario Real risk
Frame the login page to "steal credentials" None by framing alone. Clickjacking redresses clicks, not keystrokes; the same-origin policy stops a parent frame from reading a cross-origin child's DOM or input. Credential theft needs phishing/XSS/keylogging, which framing does not provide.
Frame an authenticated page, trick a logged-in user into one state-changing click (UI redressing) This is the actual risk. If a one-click destructive action exists on a framable authed route (delete account, dis/connect social account, publish now, invite user / change role, cancel subscription) with no confirm/re-auth, an overlay can harvest the click. [VERIFY] whether any such single-click action exists.
Phishing credibility Low–Medium — the real framed UI lends legitimacy to a phishing wrapper.

Severity: Low–Medium, not Critical. The fix is unconditional and cheap, so just do it. See WC-SEC-16 for the external report this maps to and the consolidated nginx remediation.

Fix (Phase 0): add both X-Frame-Options: DENY (legacy) and Content-Security-Policy: frame-ancestors 'none' (authoritative) to nginx.conf — this is the same edit as WC-SEC-7 and is folded into the consolidated nginx block in WC-SEC-16 (the minimal change to close the external report). Use frame-ancestors 'self' https://<origin> instead of 'none' if any first-party embedding (e.g. an internal tool framing the app) is legitimate. Separately resolve [VERIFY-SEC-7]: if a single-click destructive action exists on a framable authed route, add a confirm/re-auth step to it regardless of the header (defence in depth).

15. WC-SEC-14 — Stack trace exposure

Production builds do not ship source maps (ls dist/_nuxt/*.js.map → empty). Good for security; bad for observability (see observability.md).

The build does generate them in .nuxt/dist/ for development. Confirm they're not deployed: re-grep dist/ after a yarn build on CI.

Fix (Phase 2 — keep the current good state; no exposure today):

  1. Make the no-source-maps guarantee explicit rather than incidental: set build: { babel: {...}, /* ensure */ } and the Webpack devtool to false for production in nuxt.config.js → build so a config change can't accidentally re-enable client maps.
  2. Add a CI assertion after yarn build that fails if maps leak: ! ls dist/_nuxt/*.js.map 2>/dev/null (and the same for .nuxt/dist/client/), wired into .github/workflows/dev_app.yml.
  3. If/when source maps are wanted for observability (Sentry — see observability.md), upload them to the error tracker as a build artifact and delete them from the deployed dist/ rather than serving them publicly.

16. WC-SEC-14b — Ad-blocker / extension breakage of payment SDKs

(Surfaced via existing dev doc ../10-troubleshooting.md § "Stripe / Paddle script not loading".)

Severity: Medium (loss of revenue, not a security exposure per se — but operationally critical).

Stripe.js (https://js.stripe.com/v3/) and Paddle.js (https://cdn.paddle.com/paddle/v2/paddle.js) are loaded as global CDN scripts via nuxt.config.js → head.script. Common ad-blockers and privacy extensions block these URLs, so checkouts silently fail for a non-trivial percentage of users.

The cross-cutting concern: combined with WC-OBS-1 (no error tracking), the team only learns about broken checkouts via support tickets — typically days after the user gave up.

Fix:

  1. Phase 0 (load on demand): move Stripe and Paddle SDK loads to the routes that actually need them (billing, payment). Reduces blast radius — non-checkout users are unaffected by the block.
  2. Phase 0 (detect + message): at checkout init, race the SDK load against a 2-3s timeout and a window-property check. If the SDK didn't load, show the user a clear "your browser extension is blocking our payment provider; please disable it for this domain" message instead of a stuck spinner.
  3. Phase 0 (instrument): once Sentry is in place, capture the SDK-load failure as a tracked event with a tag for ad-block detection. The team can then size the actual impact.

Cross-references: - observability.md § WC-OBS-1 (no error tracking) - performance.md § "Third-party blocking scripts" (cross-cutting issue with deferred loading) - enterprise-readiness.md § Risk Register WC-RISK-14

17. WC-SEC-15 — CORS

The frontend and backend run on different origins (frontend app.someli.ai, backend process.env.API_URL). Cookies use SameSite=None; Secure. The backend is therefore configured to accept cross-origin requests with credentials.

This is fine if the backend has a strict Access-Control-Allow-Origin whitelist (one specific frontend origin, not *). [VERIFY-SEC-5]: confirm backend CORS configuration.

Fix (Phase 1 — lands in someli-api, coordinate with backend owner; not in this repo):

  1. Resolve the verify: check the backend cors() setup in someli-api/server.js / someli-api/routes/routes.js. A wildcard Access-Control-Allow-Origin: * is incompatible with Access-Control-Allow-Credentials: true per spec — if both are present, browsers reject credentialed requests, so the real risk is a reflected/echoed Origin.
  2. Replace any echoed-origin or wildcard with an explicit allow-list keyed off env, e.g.:
const allowed = (process.env.CORS_ORIGINS || 'https://app.someli.ai').split(',')
app.use(cors({
  origin: (origin, cb) => cb(null, !origin || allowed.includes(origin)),
  credentials: true,
}))
  1. Pin the front-end cookie's SameSite: once the session cookie becomes HttpOnly + server-set (WC-AUTH-2), prefer SameSite=Lax over the current SameSite=None; only keep None if a genuine third-party (cross-site) context needs the cookie. None is the real smell flagged by the WC-SEC-16 triage.
  2. Verify cross-origin behaviour after the change: curl -i -H "Origin: https://evil.example" https://<api-host>/some-endpoint should not echo the attacker origin back in Access-Control-Allow-Origin.

18. WC-SEC-16 — External clickjacking report triage (2026-05-22)

Source: unsolicited email, "Security Vulnerability Disclosure Report 01 — Clickjacking on Login Leading to Account Takeover, XSS, DOM-XSS, CSRF, Account Deletion, Privilege Escalation, Malware Execution, Victim PC Hijack…". Reported URL https://app.someli.ai/#login. Sender confirmed legitimate by the recipient; triaged here as good-faith disclosure.

Verdict: one real, low-severity item (the app is framable — already WC-SEC-7 / WC-SEC-13); the rest is templated escalation that the supplied proof-of-concept does not demonstrate.

Claim-by-claim

Report claim Verdict Basis
App loads in a cross-origin <iframe> Confirmed No X-Frame-Options/frame-ancestors (WC-SEC-7, WC-SEC-13).
Clickjacking "steals email + password" on login False Clickjacking redresses clicks, not keystrokes; SOP blocks parent→cross-origin-child DOM/input access. Credential theft needs phishing/XSS/keylogging.
XSS Not demonstrated here No injection point shown. A separate real v-html XSS risk exists (WC-SEC-4) — but clickjacking does not "chain into" it.
DOM-based XSS Unsubstantiated location.hash/search are not fed to innerHTML/v-html/eval in the login/hash flow (verified below).
CSRF Largely N/A Auth uses a bearer token sent as Authorization header, not ambient cookie auth, so classic CSRF has limited reach. SameSite=None (WC-SEC-15) is the real, separate smell.
Account deletion / privilege escalation Misattributed Real analogue is client-side role gating (WC-AUTH-4), an A01 broken-access-control issue reachable via the API with or without a frame.
"Malware execution" / "victim PC hijack" / "total control of PC" False A framed web page cannot run native malware or take over the device.
"Takeover of ALL user accounts" False No mass-compromise primitive in the report.

Tell-tale of a templated/beg-bounty report: a single framable URL expanded into an exhaustive list of unrelated catastrophic outcomes "by chaining". The report's own PoC is an <a onmouseover="window.open('https://alphainferno.com/webmail')"> link that opens the attacker's page in a new tab — it does not capture or replay any click against the framed Someli app. (alphainferno.com/webmail is attacker-controlled; do not visit it from a work machine.)

Why the buzzwords still point at real (separate) findings

The report is wrong about causation but the words map to genuine items already in this doc:

  • XSS → v-html sinks, esp. components/Chatbot.vue rendering AI output (WC-SEC-4); combined with the token being readable by same-origin JS (WC-AUTH-2), an XSS there would enable ATO — independent of framing.
  • DOM-XSS → searched, not found. grep -rEn "location\.(hash|search|href)" pages middleware plugins helpers components store | grep -iE "innerHTML|document.write|v-html|eval" → empty. The innerHTML writes that do exist assign static literals or a locally-generated passphrase, not request input.
  • Privilege escalation / account takeover → role gating runs in the browser: middleware/head.js branches on roleType === 5 / account_role === 3 from user-editable cookies; middleware/redirect.js re-logs-in social users by POSTing sentinel password '##########' with social:true + a sid from the usedetail cookie. Tracked as WC-AUTH-4. 🔴 [VERIFY-SEC-6] is now RESOLVED and is the most serious issue uncovered by this whole exercise: the backend POST /webauthenticate handler (someli-api/routes/routes.js:6539-6620) lets social:true mint a valid session token for any social-login account given only the victim's email — sid is OR-joined and non-secret, and the password compare is bypassed unconditionally. The same handler is also vulnerable to unauthenticated SQL injection. So the report's "account takeover" buzzword is real — but the cause is a backend auth-bypass, not clickjacking. Full write-up: ../../someli-api/authentication.md § Web Login (F1–F5). Fix this before the cosmetic header work.

Consolidated nginx remediation (closes WC-SEC-13/16, WC-SEC-7, WC-SEC-17, WC-SEC-18)

nginx.conf is the deployed config (Dockerfile:139 → COPY nginx.conf /etc/nginx/nginx.conf), so this is the place to fix it:

# anti-framing (closes the report)
add_header X-Frame-Options "DENY" always;                  # legacy browsers
add_header Content-Security-Policy "frame-ancestors 'none'" always;  # authoritative

# defence-in-depth (WC-SEC-7)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;

ssl_protocols TLSv1.2 TLSv1.3;   # WC-SEC-17: was TLSv1 TLSv1.1 TLSv1.2 TLSv1.3
server_tokens off;               # WC-SEC-18: was commented out

Caveats: add_header is not inherited into a location that defines its own add_header — repeat if needed. Verify with curl -I https://app.someli.ai. A full script-src CSP remains the larger Phase-0 item (WC-SEC-2); frame-ancestors alone is the minimal change to close this report and folds into the full CSP later.

Suggested reply to the reporter

Thank you. We confirmed app.someli.ai did not send X-Frame-Options/Content-Security-Policy: frame-ancestors and are deploying them. We could not reproduce the credential-theft, XSS, CSRF, account-deletion, or RCE outcomes described — clickjacking on a login form does not expose typed credentials, and the supplied PoC demonstrates a new-tab redirect rather than a framed-click capture. If you have a concrete, reproducible PoC for any additional impact, we'll review it. We do not currently run a paid bounty program.

Keep it factual; do not confirm impacts that were not demonstrated.

19. WC-SEC-17 — Deprecated TLS versions enabled

nginx.conf:36:

ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE

TLS 1.0 and TLS 1.1 are deprecated (RFC 8996, 2021) and prohibited by PCI-DSS — relevant because the app handles payments (Stripe/Paddle). They permit downgrade and weak-cipher negotiation (BEAST). Since nginx.conf is baked into the deployed image (Dockerfile:139), this applies wherever this nginx terminates TLS; if TLS is actually terminated at an upstream load balancer, fix it there too and confirm with an SSL Labs scan.

Severity: Medium. Fix: ssl_protocols TLSv1.2 TLSv1.3;

20. WC-SEC-18 — nginx version disclosure

nginx.conf:26 — # server_tokens off; is commented out, so nginx emits its version in the Server: response header and on default error pages, aiding version-specific fingerprinting.

Severity: Low. Fix: uncomment server_tokens off; inside http {}.

Non-security note (logged during triage): middleware/appendUTM.js only rewrites links whose href starts with http://localhost:3000/topics, so UTM propagation silently no-ops in production. A latent bug, not a vulnerability.

21. Recommendations — phased

Phase 0a (this week)

  • Rotate the leaked AWS key (CRITICAL). Audit CloudTrail, then rotate.
  • Delete plugins/aws_sdk.js, rewrite git history.
  • Add Subresource Integrity to all CDN <script> tags.
  • Verify no OAuth client secrets in production bundle (grep).

Phase 0 (months 0–3)

  • Add baseline security headers in nginx (CSP, HSTS, X-Frame-Options + frame-ancestors, X-Content-Type-Options, Referrer-Policy, Permissions-Policy) — closes WC-SEC-13 / WC-SEC-16. Quick win, do first.
  • Drop TLS 1.0/1.1 in nginx (ssl_protocols TLSv1.2 TLSv1.3;) and uncomment server_tokens off; — WC-SEC-17 / WC-SEC-18.
  • Sanitise the 9 v-html use sites (DOMPurify or refactor to interpolation).
  • Set up Dependabot security-only auto-PRs.
  • Run yarn audit and triage critical/high vulns; create issues for each.
  • Add gitleaks or equivalent secret-scanner to CI.
  • Move session token to httpOnly cookie (requires backend coordination).
  • Migrate aws-sdk v2 → v3 modules where actually needed; remove the v2 dep.
  • Move client-side role gating to per-navigation backend fetch.

Phase 1 (months 3–9)

  • Remove unused server-side npm packages from frontend (stripe Node SDK, @sendgrid/mail, passport* if not needed in build).
  • Consolidate auth header to one canonical format (drop token and raw Authorization in favour of Authorization: Bearer).
  • Add token-refresh path (response interceptor on 401).
  • Tighten CSP — eliminate unsafe-inline, move inline analytics to nonced files.
  • Consolidate UI library duplicates (3 emoji pickers, 3 date pickers, 3 toast libs, 2 cookie libs, 2 sortable libs).

Phase 2 (months 9–18)

  • Vue 2 → Vue 3 + Nuxt 2 → Nuxt 3 migration. Fundamentally fixes WC-SEC-5.
  • TypeScript adoption (or at least Zod schema validation at API service layer).

Phase 3 (months 18+)

  • Full SOC 2 Type I readiness (combined with backend).

22. Open questions

Tracked in ./verify-markers.md:

  • [VERIFY-SEC-1] GOOGLE_API_KEY scope and bundle inclusion
  • [VERIFY-SEC-2] EXPRESS_SECRET, OAuth client secrets — not in client bundle
  • [VERIFY-SEC-3] Token-in-URL spot check
  • [VERIFY-SEC-4] Polotno license terms (public key tolerance)
  • [VERIFY-SEC-5] Backend CORS whitelist configuration
  • [VERIFY-SEC-6] Backend webauthenticate validates sid server-side; social:true sentinel-password path ('##########') cannot be replayed for an arbitrary victim email (raised by WC-SEC-16 triage)
  • [VERIFY-SEC-7] Whether any single-click destructive action (delete account, dis/connect social, publish, invite/change role, cancel subscription) is reachable on a framable authenticated route without a confirm/re-auth step — determines the true severity of WC-SEC-13

Plus all [VERIFY-AUTH-*] markers from authentication-client.md.