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 → pluginsand is not imported anywhere inpages/,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):
- Rotate the AWS access key immediately in AWS IAM. Do this before any other change — the rotation cannot wait.
- Audit AWS CloudTrail for this access-key-id since 2022-08-01 to identify any unauthorised use.
- Delete
plugins/aws_sdk.jsfrom the working tree. - Run
git-filter-repoorbfgto rewrite the keys out of git history (note: this is destructive history rewriting; coordinate with all clones). - After rewrite, force-push and require all engineers to re-clone.
- Add a pre-push secret-scanner (
gitleaksin 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.
- Backend: have
someli-apiset the session cookie itself withHttpOnly; Secure; SameSite=Lax(orNoneonly if a third-party context is required) on the/authenticate//webauthenticateresponses, instead of returning the token in the JSON body for the SPA to store. (someli-api/routes/routes.jslogin handlers.) - Frontend auth strategy: in
nuxt.config.js:285-298, the@nuxtjs/authcookie/strategiesblock currently relies oncookie-universal-nuxtto write a JS-readabletokencookie. Switch the strategy totokenType: false/ server-set cookie mode so the browser never writes or reads the token, and remove the manual$cookies.set('token', …)/usedetailwrites (store/index.js,middleware/redirect.js,pages/index.vue,pages/socialAuth/index.vue). - Send credentialed requests with
axioswithCredentials: trueso the HttpOnly cookie rides along automatically (middleware/axios.js), and drop the explicitAuthorization/tokenheader injection that reads the cookie from JS. - Stop storing the full user record in the browser-readable
usedetailcookie; fetch it from an authenticated/meendpoint into Vuex on boot instead. This also removes the user-editableroleType/account_roletrust problem (WC-AUTH-4). - 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¶
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:
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):
- Add a baseline CSP header on the nginx side (
nginx.conf, inside eachserver {}/location /that serves the SPA — see WC-SEC-16's consolidated block;add_headeris not inherited into alocationwith its ownadd_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) forscript-src, and addconnect-srcfor the API origin and analytics beacons. - Move inline scripts to nonce-based CSP if/when feasible (requires per-request nonce generation, hard with
nuxt generate— easier withnuxt start). - Phase out
unsafe-inlineby 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):
- For each version-pinned CDN script in
nuxt.config.js:23-119 → head.script, generate the SRI hash and addintegrity+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):
- Install
dompurify(~12 KB gzipped; not currently a dependency — confirmed absent frompackage.json):yarn add dompurify. - In
helpers/helper.js(the existing shared-helper module, alongsidehandleImageError/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:
})
- Apply it at every sink:
components/Chatbot.vue:1421-1434(formatMessage) and:1436-1453(formatProfileContent) — wrap the returned string insafeHtml(...), and HTML-escape the interpolatedvalue/formattedKeybefore building the string so an attacker can't inject markup that survives an allow-listed tag.components/Layout/EaMainSection.vue(2),components/Forms/EaInputSwitch.vue(1),components/Dashboard/EaGauge.vue(2) — bindv-html="safeHtml(...)", or refactor to plain{{ }}interpolation where the value is text-only (preferred — removes the sink entirely).- Add the ESLint rule
vue/no-v-html: 'error'(fromeslint-plugin-vue) to.eslintrc, with a narrowly-scoped// eslint-disable-next-line vue/no-v-htmlcomment only on the sanitised lines, so any newv-htmlfails 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):
- 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 dependenciesto 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. - 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 independencies-inventory.md. - 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. - 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):
- Commit
.github/dependabot.yml(currently absent — confirmedls .github/dependabot.yml→ not found; the repo only has.github/workflows/dev_app.ymlanduat_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"
- 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.
- Add a
yarn auditgate to CI (.github/workflows/dev_app.yml): runyarn audit --level high; test $? -lt 4(yarn's exit code is a bitmask;<4excludes 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 HTTPSX-Frame-Options/frame-ancestors(CSP) — clickjackingX-Content-Type-Options: nosniff— MIME sniffingReferrer-Policy— referrer leakagePermissions-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— publicSTRIPE_PUBLIC_KEY_LIVE/STRIPE_PUBLIC_KEY_TEST— public by Stripe designPADDLE_CLIENT_TOKEN/PADDLE_TEST_CLIENT_TOKEN— public by Paddle designFACEBOOK_CLIENT_ID,TWITTER_CLIENT_ID,TWITTER_CUSTOMER_KEY— public OAuth client IDsGTM_ID,CLARITY_PROJECT_ID— public analytics IDsYOUTUBE_API_KEY— used only byapi/index.jsserver middleware, which runs in the Nuxt Node process, not the browser. Not in the client bundle (verify viagrep "YOUTUBE_API_KEY" dist/_nuxt/*.jsafter 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:
- 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. - Confirm the secret is referenced via
process.env.*server-side and is not placed innuxt.config.js → publicRuntimeConfigorenv(those inline into the client bundle); useprivateRuntimeConfig/ directprocess.envinapi/instead. - Rotate the leaked secret with the provider (Twitter/X, Facebook) — a value that shipped in any past bundle must be treated as compromised.
- 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¶
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¶
[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):
- Resolve the verify first:
grep -rEn "[?&](token|access_token|sid)=" pages components middleware plugins storeand 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. - 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. - 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 andRefererheaders — and clear it immediately after read. - 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):
- 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."
- 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.envdoesn't silently fall back to a committed production ID. They are not secrets, so this is hygiene/Phase 2, not urgent. - 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 viaprocess.env.POLOTNO_LICENSEat 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. - 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-refdependencies-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):
- Sweep for any remaining
http://references:grep -rEn "(src|href|url)\s*[=:]\s*[\"']?http://" pages components plugins assets nuxt.config.js | grep -v localhost. - Rewrite any found to
https://(or protocol-relative is not sufficient on a TLS-only site — use explicithttps://). - 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;plusblock-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):
- Make the no-source-maps guarantee explicit rather than incidental: set
build: { babel: {...}, /* ensure */ }and the Webpackdevtooltofalsefor production innuxt.config.js → buildso a config change can't accidentally re-enable client maps. - Add a CI assertion after
yarn buildthat 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. - 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 deployeddist/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:
- 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.
- 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.
- 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):
- Resolve the verify: check the backend
cors()setup insomeli-api/server.js/someli-api/routes/routes.js. A wildcardAccess-Control-Allow-Origin: *is incompatible withAccess-Control-Allow-Credentials: trueper spec — if both are present, browsers reject credentialed requests, so the real risk is a reflected/echoedOrigin. - 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,
}))
- Pin the front-end cookie's
SameSite: once the session cookie becomes HttpOnly + server-set (WC-AUTH-2), preferSameSite=Laxover the currentSameSite=None; only keepNoneif a genuine third-party (cross-site) context needs the cookie.Noneis the real smell flagged by the WC-SEC-16 triage. - Verify cross-origin behaviour after the change:
curl -i -H "Origin: https://evil.example" https://<api-host>/some-endpointshould not echo the attacker origin back inAccess-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-htmlsinks, esp.components/Chatbot.vuerendering 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. TheinnerHTMLwrites 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.jsbranches onroleType === 5/account_role === 3from user-editable cookies;middleware/redirect.jsre-logs-in social users by POSTing sentinel password'##########'withsocial:true+ asidfrom theusedetailcookie. Tracked as WC-AUTH-4. 🔴 [VERIFY-SEC-6] is now RESOLVED and is the most serious issue uncovered by this whole exercise: the backendPOST /webauthenticatehandler (someli-api/routes/routes.js:6539-6620) letssocial:truemint a valid session token for any social-login account given only the victim's email —sidis 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.aidid not sendX-Frame-Options/Content-Security-Policy: frame-ancestorsand 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:
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.jsonly rewrites links whose href starts withhttp://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 uncommentserver_tokens off;— WC-SEC-17 / WC-SEC-18. - Sanitise the 9
v-htmluse sites (DOMPurify or refactor to interpolation). - Set up Dependabot security-only auto-PRs.
- Run
yarn auditand triage critical/high vulns; create issues for each. - Add
gitleaksor equivalent secret-scanner to CI. - Move session token to httpOnly cookie (requires backend coordination).
- Migrate
aws-sdkv2 → 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 (
stripeNode SDK,@sendgrid/mail,passport*if not needed in build). - Consolidate auth header to one canonical format (drop
tokenand rawAuthorizationin favour ofAuthorization: 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_KEYscope 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
webauthenticatevalidatessidserver-side;social:truesentinel-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.