Skip to content

Security

Findings

F-1: Auth-redirect may be missing (HIGH if true — VERIFY)

nuxt.config.js does not register an auth middleware globally; pages don't seem to declare middleware: 'auth' per spot-checks. Verify: try visiting /dashboard without a token. If no redirect, unauthenticated visitors can hit protected page chrome (though API calls will 401).

Re-verify note (2026-05-22): a grep of pages/ shows middleware: 'auth' IS declared on 53 pages (e.g. pages/AI-Content.vue:57). Coverage is therefore per-page, not global — so any new page that omits the line ships unguarded. The risk is "easy to forget on new pages", not "nothing is guarded". Fix below makes the guard global so individual pages can't opt out by omission.

Fix: 1. Make the guard global instead of per-page. In nuxt.config.js the router block currently registers only axios (nuxt.config.js:49-51):

router: {
  middleware: 'axios',
},
Change it to chain auth first so every route is guarded by default:
router: {
  middleware: ['auth', 'axios'],
},
@nuxtjs/auth ships an auth middleware that redirects unauthenticated requests to the configured login path. 2. Add auth: { redirect: { login: '/login', logout: '/login', home: '/dynamicPost' } } to the auth block in nuxt.config.js:65 so redirects are explicit rather than relying on defaults. 3. Public pages (e.g. pages/login.vue) must opt OUT of the global guard with export default { auth: 'guest' } (or auth: false) in their component options — the repo already has a middleware/guest.js for this intent (middleware/guest.js:1-14); consolidate on the built-in auth: 'guest' and delete the hand-rolled guest.js. 4. Regression-check: with a cleared cookie/localStorage, visit /dynamicPost, /topics, /tempsets — each should now bounce to /login rather than render chrome.

@nuxtjs/auth's local strategy default stores the token in localStorage + a non-httpOnly cookie. The user record is also in the userdetail cookie (read back in middleware/guest.js:3 and the plugins/hotjar.js cookie reads). Both XSS-readable.

Fix: (requires designer-api coordination — see ../designer-api/security.md) 1. BE: on successful webauthenticate (nuxt.config.js:70), set the session token as an HttpOnly cookie instead of returning it in the JSON body:

// designer-api login handler
res.cookie('auth_token', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'Strict',
  maxAge: 1000 * 60 * 60 * 8, // align with token TTL (see F-3)
});
2. FE: switch the @nuxtjs/auth local strategy to cookie mode so the SDK stops mirroring the token into localStorage. In the auth block (nuxt.config.js:65-76):
auth: {
  strategies: {
    local: {
      token: { property: false },   // token no longer in JSON body
      user:  { property: 'data' },
      endpoints: {
        login:  { url: 'webauthenticate', method: 'post' },
        user:   { url: 'me', method: 'get', propertyName: 'data' },
        logout: { url: 'logout', method: 'post' }, // see F-3
      },
    },
  },
},
3. Drop the userdetail cookie entirely; hydrate the user record from GET /me (user endpoint at nuxt.config.js:71) on app boot via $auth.fetchUser(). Remove the store.$cookies.get("userdetail") read in middleware/guest.js:3-6. 4. Because the cookie is now HttpOnly, CSRF protection becomes relevant — confirm BE checks SameSite=Strict is honoured and add a CSRF token if any state-changing request can be triggered cross-site (revisit the "What's not a finding → CSRF" note once this lands).

F-3: No server-side logout (MEDIUM)

auth.strategies.local.endpoints.logout: false (nuxt.config.js:72) means the FE doesn't call any BE logout endpoint. Tokens remain valid until the BE token format expires (which, per BE audit, is "never").

Fix: (requires designer-api coordination) 1. BE: add a POST /logout handler in designer-api/routes/routes.js that revokes the presented token (push it into the revocation store / delete the server-side session) and clears the cookie (res.clearCookie('auth_token')). 2. FE: replace logout: false with the real endpoint in nuxt.config.js:72:

logout: { url: 'logout', method: 'post' },
@nuxtjs/auth will then call it during $auth.logout() before clearing local state. 3. Wire a logout control in components/Navbar.vue (the authenticated nav block at Navbar.vue:7) that invokes this.$auth.logout(). 4. Pair this with finite server-side token TTL (see ../designer-api/security.md) so a process restart that wipes an in-memory revocation list does not silently re-validate old tokens.

F-4: Role gating UI-only (MEDIUM)

The Navbar hides nav items by role_type but a knowledgeable user can navigate by URL. The gating is a thicket of inline v-if checks (e.g. components/Navbar.vue:29, :33, :44-47, :58-59, :65-71) comparing $auth.user.role_type against magic numbers (1/2/6/12/13). Whether that exposes data depends on BE enforcement. If the BE doesn't check role_type consistently per-handler, the role gating is cosmetic.

Fix: 1. Treat the FE checks as UX-only and enforce on the BE: audit every handler in designer-api/routes/routes.js for a consistent role check, and add a requireRole(...allowed) middleware applied at the route level (BE side — see ../designer-api/security.md). This is the authoritative control. 2. FE hardening (defence-in-depth): mirror the role logic in a route guard so URL-typing also bounces. Create middleware/role.js:

export default function ({ store, route, redirect }) {
  const role = store.$auth.user?.role_type
  const adminOnly = ['/prompts', '/subjectsDemands', '/userActivityLog', '/jobs']
  if (adminOnly.includes(route.path) && ![1, 2].includes(role)) return redirect('/')
}
and add it to the global router.middleware chain in nuxt.config.js:49 (['auth', 'role', 'axios']). 3. Replace the scattered magic-number role_type literals in Navbar.vue with named constants (e.g. a helpers/roles.js map) so FE and the new guard stay in sync and reviewers can read intent. 4. Document the BE as the source of truth in the handler audit so future contributors don't mistake the v-if hiding for an access control.

F-5: 3rd-party CDN scripts in <head> without SRI (LOW)

nuxt.config.js head:

{ src: 'https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js', type: 'text/javascript' }

No integrity / crossorigin attributes (the script entry is at nuxt.config.js:16-20). If the CDN is compromised, the script can execute arbitrary JS in designer staff browsers.

Fix: 1. Generate the SRI hash for the pinned version:

curl -s https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js \
  | openssl dgst -sha384 -binary | openssl base64 -A
2. Add integrity + crossorigin to the head.script entry in nuxt.config.js:16-20:
script: [
  {
    src: 'https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js',
    type: 'text/javascript',
    integrity: 'sha384-<base64-from-step-1>',
    crossorigin: 'anonymous',
  },
],
With SRI the browser refuses to execute a tampered file (the hash won't match). 3. Preferred long-term: bootstrap@5.3.3 is already a direct npm dependency (package.json). Drop the CDN <script> entirely and import 'bootstrap/dist/js/bootstrap.bundle.min' from a Nuxt plugin so the file is bundled (no third-party origin to trust, and it resolves the version mismatch in F-7). 4. Re-generate the SRI hash whenever the pinned version changes — a stale hash silently blocks the script.

F-6: No CSP (MEDIUM)

Nuxt doesn't set CSP by default. Whether CSP is configured depends on the nginx layer in the Dockerfile-built image (Dockerfile:77 does COPY nginx.conf /etc/nginx/nginx.conf, but nginx.conf is not committed in this repo — a find for nginx* returns nothing — so the policy cannot be reviewed here). No in-repo CSP config observed.

Fix: 1. Commit nginx.conf to the repo (it's referenced by Dockerfile:77 but absent) so the security headers are reviewable and version-controlled. 2. In the server { } block, add CSP plus the related hardening headers. Because the editor loads jsDelivr (Bootstrap), Hotjar (plugins/hotjar.js), and Polotno/Pexels assets, the policy must whitelist those origins:

add_header Content-Security-Policy "default-src 'self'; \
  script-src 'self' https://cdn.jsdelivr.net https://static.hotjar.com https://script.hotjar.com; \
  style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; \
  img-src 'self' data: blob: https:; \
  connect-src 'self' https://*.hotjar.com https://api.polotno.com https://api.pexels.com; \
  frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
(Vue 2 inline event handlers and Polotno may force 'unsafe-inline' for style-src; keep script-src free of 'unsafe-inline' and test the editor — tighten as far as the app tolerates.) 3. Add the companion headers in the same block:
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" 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;
4. Roll out CSP with Content-Security-Policy-Report-Only first, watch the browser console for violations across the editor pages, then flip to enforcing.

F-7: Bootstrap-Vue v2 ↔ Bootstrap v5 mismatch (LOW; UX, not security)

Loading two incompatible Bootstrap versions can cause unexpected DOM mutations. bootstrap-vue@2.23.1 targets Bootstrap 4, while bootstrap@5.3.3 is also a dependency and Bootstrap 5 JS is pulled via CDN (nuxt.config.js:16-20, package.json). Less a security finding than a UX risk; mentioned here because it affects auth-related UI (modals, dropdowns) where consistent behaviour matters.

Fix: 1. Pick one Bootstrap line. bootstrap-vue@2.x is built for Bootstrap 4 — either downgrade the CSS/JS to bootstrap@4.6.x, or migrate off bootstrap-vue onto a Bootstrap-5-native component set. Don't ship 4 and 5 simultaneously. 2. Remove the redundant CDN <script> (nuxt.config.js:16-20) once the version is settled, importing the chosen Bootstrap bundle from npm in a plugin (this also closes F-5's SRI gap). 3. Smoke-test modals, dropdowns and the navbar (components/Navbar.vue) after consolidating, since those are the components most sensitive to the version clash.

F-8: Polotno editor localStorage design persistence (LOW)

The Polotno editor persists in-progress designs and brand data to localStorage (e.g. polotno-editor/brandkitPanel.js:59,105,142,183 write colorSetData/brand flags; polotno-editor/helper.js:30 writes previousColorState; polotno-editor/App.js:86-92 tracks header/piece; polotno-editor/carousaltemplatesPanel.js:18,38-43 store loggedInUserId/editid/editTitle). PII risk: designs may contain customer brand assets, draft copy, internal notes. Designer staff sharing browsers could leak. (Internal tool — moderate risk; document.)

Separately, a Polotno license key is hardcoded in the editor source: polotno-editor/index.js:7

const store = createStore({ key: "FXZvloSJvAe09-bdR9iC" });
This key is checked into git and also baked into the committed polotno-bundle.js (confirmed: 1 occurrence). Polotno keys are origin-bound at validation time, so the practical exposure is limited, but a committed key is still a leaked credential that can be lifted into another project that spoofs the origin, and rotation requires a code change + rebuild.

Fix: 1. Move the key out of source into a build-time env var. In polotno-editor/index.js:7:

const store = createStore({ key: process.env.POLOTNO_KEY });
Inject POLOTNO_KEY at bundle-build time (the editor builds via its own toolchain in polotno-editor/; expose it through the bundler's define/env mechanism). Add POLOTNO_KEY to .env (and .env.example) and ensure .env is git-ignored. 2. Rotate the existing key FXZvloSJvAe09-bdR9iC in the Polotno dashboard since it has been committed; confirm the key is origin-restricted to the staff designer domain so a copied key fails validation elsewhere, and confirm the deployment terms permit this client-side embedding model. 3. Reduce the localStorage footprint of design content: scope persisted keys to the current user, clear them on logout ($auth.logout() hook — see F-3), and avoid storing full design JSON beyond what the autosave needs. 4. Strip the design-state console.log calls in polotno-editor/App.js (e.g. App.js:81,85,97,101,113 log store.toJSON() / element data) so customer brand content and draft copy don't leak into shared-browser dev consoles. 5. On shared staff workstations, prefer per-user OS accounts / browser profiles so localStorage is not cross-readable.

F-9: bcryptjs in client deps (LOW)

bcryptjs is in the FE's package.json. If used client-side, it suggests passwords are hashed in the browser — usually a sign of a misunderstood security model (the server should hash). Verify whether bcryptjs is actually used in this FE; if not, remove.

Re-verify note (2026-05-22): it is used client-side. pages/login.vue:98 imports bcryptjs, and pages/login.vue:156-160 (hashIt) bcrypt-hashes the password in the browser before login. This is broken as an auth mechanism: a bcrypt hash with a client-generated random salt (genSaltSync(10)) is non-deterministic, so the server cannot reproduce or verify it — and whatever string is sent becomes the de-facto password, so the hash provides no protection (an attacker who captures it can replay it). Bcrypt belongs on the server, over the wire under TLS.

Fix: 1. Remove the client-side hashing: delete the import bcrypt from 'bcryptjs' at pages/login.vue:98 and the hashIt() method at pages/login.vue:156-160, and send the plaintext password to webauthenticate over HTTPS (TLS is the transport protection, not bcrypt). 2. Confirm the BE (designer-api) hashes + verifies with bcrypt server-side against the stored hash; if the BE was written to expect the client-hashed value, fix the BE comparison in the same change (coordinate — see ../designer-api/security.md). 3. Drop bcryptjs from package.json dependencies once no FE code references it, then yarn install to update the lockfile.

F-10: Hot-loaded jQuery via Bootstrap bundle (LOW)

The CDN Bootstrap 5 bundle pulls in jQuery indirectly. jQuery has a track record of XSS vectors. Audit any v-html / innerHTML usage in pages.

Re-verify note (2026-05-22): v-html is used on 7 pages, all binding AI/PDF-derived content that is not provably trusted: pages/AI-Tips.vue:38, pages/AI-Content.vue:38, pages/AI-Mistakes.vue:38, pages/AI-Questions.vue:36, pages/AI-Myths.vue:38, pages/AI-Jokes.vue:38, and pages/pdfToContent.vue:36 (v-html="p.content" from uploaded-PDF text). Each is a stored/reflected XSS sink if the rendered string can contain markup.

Fix: 1. Sanitize before rendering. Add DOMPurify (yarn add dompurify) and a small filter; register it globally, e.g. in plugins/customCommon:

import Vue from 'vue'
import DOMPurify from 'dompurify'
Vue.filter('clean', (html) => DOMPurify.sanitize(html || ''))
then change each sink from v-html="rs.content" to v-html="rs.content | clean" (and p.content | clean at pages/pdfToContent.vue:36, resContent | clean at pages/AI-Questions.vue:36). 2. Where the content is plain text and not meant to contain markup, prefer interpolation ({{ rs.content }}) over v-html entirely — that removes the sink rather than sanitizing it. 3. Combine with the CSP from F-6 (object-src 'none', no inline script-src) so any markup that slips through cannot execute scripts. 4. Eliminate the jQuery dependency by self-hosting/bundling Bootstrap (see F-5 step 3 and F-7) so a future jQuery CVE is not in the staff editor's attack surface.

Outcomes-based

  • OWASP A02 / A05 / A07 — auth posture (token storage, no expiry, no real logout)
  • OWASP A05 — missing CSP, missing SRI
  • OWASP A01 Broken Access Control — role gating UI-only

Recommendations

Priority Action
HIGH Verify F-1 (auth redirect); fix if absent
HIGH Add server-side logout (F-3)
MEDIUM Add CSP via nginx (F-6)
MEDIUM Add SRI to CDN scripts (F-5)
MEDIUM Migrate to httpOnly cookie auth (F-2, BE coord)
LOW Audit BE role-check coverage (F-4)
LOW Remove client-side bcrypt hashing + drop the bcryptjs FE dep (F-9, confirmed used at pages/login.vue:98,156-160)
LOW Self-host critical scripts to remove CDN dependency (F-5)
LOW Sanitize the 7 v-html sinks with DOMPurify (F-10)
LOW Resolve the Polotno license key out of source into an env var + rotate (F-8)

Dependency hygiene (cross-cutting — supports F-5, F-7, F-10)

The stack is EOL: Vue 2.7 (package.json) reached end-of-life Dec 2023 and Nuxt 2 is also EOL — neither receives security patches, so transitive CVEs (jQuery via Bootstrap, etc.) accumulate unmonitored.

Fix: 1. Add automated dependency alerts so new CVEs surface as PRs. Commit .github/dependabot.yml:

version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
  - package-ecosystem: "npm"
    directory: "/polotno-editor"   # editor builds with its own deps
    schedule:
      interval: "weekly"
(Also enable GitHub's Dependabot security alerts in repo Settings → Security.) 2. Run yarn audit (and cd polotno-editor && npm audit) in CI and fail the build on high/critical advisories so regressions are caught at PR time. 3. Plan the Nuxt 2 → Nuxt 3 / Vue 2 → Vue 3 migration as a tracked workstream; until then, treat every dependency PR as the only patch channel and prioritize anything touching the auth flow, v-html rendering path, or the CDN/Bootstrap chain.

What's not a finding

  • CSRF: token in header (Bearer), not cookie auth. CSRF not exploitable.
  • SQL injection / XSS via user input: BE concern, not FE; the FE renders mostly server-provided text. (Correction 2026-05-22: the FE does have its own DOM-XSS surface — 7 v-html sinks rendering AI/PDF content — now tracked as F-10. This bullet covers SQL injection only.)
  • Lovable.dev script: not present here (only in admin_console_R).
  • Hardcoded secrets: env-driven for app config (process.env.APP_TYPE, process.env.baseURL). (Correction 2026-05-22: a Polotno license key IS hardcoded in source at polotno-editor/index.js:7 and bundled into polotno-bundle.js — now tracked as F-8. This bullet applies to the Nuxt app config only, not the embedded editor.)