Files
Kordant/piolium/final-audit-report.md
2026-05-29 09:03:47 -04:00

25 KiB
Raw Permalink Blame History

Security Audit Report: Kordant

=========================================

Audit ID: 2026-05-28T13:00:30.320Z
Mode: Balanced
Commit: 26d9f8b050969dfaa2c9dfb714a872160b7db382
Date: 2026-05-28
Target: Kordant monorepo — SolidStart + tRPC + Drizzle ORM + Stripe + WebSocket + Browser Extension


Executive Summary

This audit of the Kordant monorepo identified 11 security findings: 1 High and 10 Medium severity. No Critical findings were confirmed — all valid findings require a precondition such as authenticated access, admin privileges, environment variable injection, or access to server logs.

The most significant finding is a stored XSS vulnerability (p8-001) in the blog post rendering pipeline, where unsanitized markdown content is rendered via SolidJS innerHTML, allowing any admin-controlled blog post to execute JavaScript in the context of every viewer's browser. This is the only finding rated HIGH severity.

A notable systemic pattern emerged across multiple findings: missing input validation constraints (no maximum length on audio uploads, no domain restrictions on return URLs, no Origin validation on WebSocket connections). This suggests a broader architectural issue with input validation strategy rather than isolated bugs.

The Stripe webhook handler presents two independent issues — unsafe type coercion (p8-006) and missing idempotency (p8-007) — which, when chained, allow subscription state manipulation if the webhook secret is ever exposed through logs or other channels.

The WebSocket authentication mechanism (p8-008, p8-009) is the weakest link in the authentication architecture: JWT tokens are passed in query parameters (visible in logs) and the server does not validate Origin headers, creating a complete authentication bypass chain accessible from any website.


Methodology Summary

  • Intelligence Gathering: Advisory collection covering 7+ security-sensitive dependencies (drizzle-orm, @trpc/server, valibot, superjson, jose, ws, vite), 14+ CVEs reviewed, and 88,636 WooYun case patterns
  • Knowledge Base: Threat modeling with 10 trust boundaries, 6 data-flow slices (DFD), 3 control-flow slices (CFD), and domain-specific attack research across 5 sub-domains (voice biometrics, OSINT aggregation, payments, real-time messaging, file system)
  • Static Analysis: Targeted grep/read analysis of 730 files, 218 candidate files identified, 1,412 candidate matches scored. Custom SAST targets derived from DFD/CFD slices. CodeQL and Semgrep were not available on PATH.
  • Review Chambers: Single balanced chamber evaluated 19 draft findings (12 from SAST phase, 7 from probe phase). After ideological challenge, false-positive elimination, and duplicate consolidation: 11 findings promoted (1 HIGH, 10 MEDIUM), 1 false positive rejected, 3 low-severity rejected, 4 duplicates collapsed.
  • Verification: FP-check eliminated 1 false positive (path-traversal-audio-storage — userId from ctx.user.id, not user input). No PoC scripts were constructed (all PoCs pending).

Summary of Findings

ID Title Severity PoC Status Parent
p8-001 Stored XSS via unsanitized innerHTML in blog rendering HIGH pending
p8-002 SSRF via Puppeteer --no-sandbox in report generation MEDIUM pending
p8-003 Open redirect via unvalidated return URL in Stripe checkout MEDIUM pending
p8-004 Rate limit bypass via incomplete sensitive path list MEDIUM pending
p8-005 CORS origin trust from unvalidated APP_URL env var MEDIUM pending
p8-006 Webhook type coercion bypassing TypeScript safety MEDIUM pending
p8-007 Webhook replay via missing event ID deduplication MEDIUM pending
p8-008 WebSocket JWT leakage via query parameter MEDIUM pending
p8-009 WebSocket no Origin header validation MEDIUM pending
p8-010 VoicePrint resource exhaustion via unbounded audio upload MEDIUM pending
p8-011 Browser extension vulnerable dependency (superjson CVE-2022-23631) MEDIUM pending

Severity Distribution: 0 Critical · 1 High · 10 Medium · 0 Low


Technical Findings Detail

p8-001 Stored XSS via unsanitized innerHTML in blog rendering

  • Severity: HIGH
  • Summary: Blog post rendering uses a custom contentToHtml() function with raw string concatenation combined with SolidJS innerHTML directive, allowing stored XSS.
  • Impact: Stored XSS affecting all blog post viewers. Attackers can steal session cookies/JWTs, perform account takeover, redirect users to phishing pages, and deface content. Any admin-controlled blog post with HTML/JavaScript tags executes in every viewer's browser.
  • Root Cause: The contentToHtml() function at web/src/routes/blog/[slug].tsx:14-46 performs raw string interpolation (html += \

    ${line.slice(3)}

    `) without HTML escaping. The innerHTML` directive at line 121 bypasses SolidJS's default framework-level escaping.
  • Key Code Reference: web/src/routes/blog/[slug].tsx:14-46 (contentToHtml), web/src/routes/blog/[slug].tsx:121 (innerHTML binding)
  • PoC Status: pending
  • Detailed Report: piolium/findings/p8-001-xss-in-innerhtml/report.md
  • Proof of Concept: piolium/findings/p8-001-xss-in-innerhtml/draft.md
  • Evidence: piolium/findings/p8-001-xss-in-innerhtml/

p8-002 SSRF via Puppeteer --no-sandbox in report generation

  • Severity: MEDIUM
  • Summary: Report PDF generator uses Puppeteer with --no-sandbox flag and page.setContent() accepting arbitrary HTML, enabling SSRF through database-controlled data.
  • Impact: SSRF to internal services (cloud metadata endpoints, internal APIs) and local file read via file:// URLs. The --no-sandbox flag disables Chrome sandboxing, significantly expanding the attack surface.
  • Root Cause: The generatePDF() function at web/src/server/services/reports/generator.ts:141-150 launches Puppeteer with --no-sandbox and uses page.setContent(html) with HTML compiled from database data via compileData(). No URL allowlisting or request interception is applied.
  • Key Code Reference: web/src/server/services/reports/generator.ts:141-150 (generatePDF), web/src/server/services/reports/generator.ts:53-137 (compileData)
  • PoC Status: pending
  • Detailed Report: piolium/findings/p8-002-puppeteer-ssrf/report.md
  • Proof of Concept: piolium/findings/p8-002-puppeteer-ssrf/draft.md
  • Evidence: piolium/findings/p8-002-puppeteer-ssrf/

p8-003 Open redirect via unvalidated return URL in Stripe checkout

  • Severity: MEDIUM
  • Summary: Billing checkout and portal schemas validate returnUrl using valibot's url() validator which checks format only — no domain restriction. URL is passed directly to Stripe APIs.
  • Impact: Open redirect attacks via Stripe checkout/portal return URLs. Users are redirected to phishing pages mimicking Kordant's branding after payment. The redirect URL includes the Stripe session ID, enabling session fixation attacks.
  • Root Cause: CreateCheckoutSessionSchema at web/src/server/api/schemas/billing.ts:4-6 uses string([url()]) for returnUrl validation, which only validates URL format. The URL is passed directly to stripe.checkout.sessions.create() without domain allowlisting.
  • Key Code Reference: web/src/server/api/schemas/billing.ts:4-6, 9-10 (schemas), web/src/server/api/routers/billing.ts:43-54, 68-75 (usage)
  • PoC Status: pending
  • Detailed Report: piolium/findings/p8-003-open-redirect-return-url/report.md
  • Proof of Concept: piolium/findings/p8-003-open-redirect-return-url/draft.md
  • Evidence: piolium/findings/p8-003-open-redirect-return-url/

p8-004 Rate limit bypass via incomplete sensitive path list

  • Severity: MEDIUM
  • Summary: Rate limiting middleware uses substring matching (path.includes(p)) with an incomplete sensitive list covering only auth operations. Sensitive operations like darkwatch.runScan and voiceprint.analyzeAudio get the standard authenticated tier (100/min) instead of stricter sensitive tier (3/hr).
  • Impact: Resource exhaustion and cost abuse for sensitive operations. darkwatch.runScan can trigger 100 external API calls per minute (HIBP, SecurityTrails, Censys, Shodan), and voiceprint.analyzeAudio can consume 300MB+ memory per request at high frequency.
  • Root Cause: The rate limiter at web/src/server/api/utils.ts:35-38 uses sensitivePaths.some(p => path.includes(p)) where sensitivePaths is hardcoded to ["login", "signup", "forgotPassword", "resetPassword"]. This substring heuristic misses most sensitive operations.
  • Key Code Reference: web/src/server/api/utils.ts:35-38 (rate limiting middleware)
  • PoC Status: pending
  • Detailed Report: piolium/findings/p8-004-rate-limit-substring-bypass/report.md
  • Proof of Concept: piolium/findings/p8-004-rate-limit-substring-bypass/draft.md
  • Evidence: piolium/findings/p8-004-rate-limit-substring-bypass/

p8-005 CORS origin trust from unvalidated APP_URL env var

  • Severity: MEDIUM
  • Summary: CORS middleware trusts process.env.APP_URL as an allowed origin without domain validation. If an attacker can control this environment variable, they can set an arbitrary allowed CORS origin with credentials.
  • Impact: Full cross-origin data exfiltration from the tRPC API. An attacker-controlled origin can read all authenticated tRPC procedures including user profiles, billing data, darkwatch exposure data, voiceprint analysis results, and admin statistics.
  • Root Cause: The CORS middleware at web/src/middleware.ts:22-30 builds allowedOrigins from ["http://localhost:3000", "http://localhost:3001", process.env.APP_URL] without validating that APP_URL is a legitimate domain.
  • Key Code Reference: web/src/middleware.ts:22-30 (CORS middleware)
  • PoC Status: pending
  • Detailed Report: piolium/findings/p8-005-cors-origin-env-var/report.md
  • Proof of Concept: piolium/findings/p8-005-cors-origin-env-var/draft.md
  • Evidence: piolium/findings/p8-005-cors-origin-env-var/

p8-006 Webhook type coercion bypassing TypeScript safety

  • Severity: MEDIUM
  • Summary: Stripe webhook handler uses chained type coercion (as unknown as Record<string, unknown>) that bypasses TypeScript type safety. Malformed webhook data produces silent failures — undefined values, NaN dates — stored in the database without validation.
  • Impact: Incorrect subscription state updates, data corruption (Invalid Date values, undefined fields), and potential data integrity issues if Drizzle accepts unexpected fields via set(data as Record<string, unknown>).
  • Root Cause: The webhook handler at web/src/server/services/billing.service.ts:173, 196, 207 casts event data to Record<string, unknown>, allowing any field type. When fields like current_period_start are undefined, the cast to number produces NaN, creating Invalid Date values.
  • Key Code Reference: web/src/server/services/billing.service.ts:173, 196, 207 (coercion points), web/src/server/services/billing.service.ts:115-132 (updateSubscriptionInDB)
  • PoC Status: pending
  • Detailed Report: piolium/findings/p8-006-webhook-type-coercion/report.md
  • Proof of Concept: piolium/findings/p8-006-webhook-type-coercion/draft.md
  • Evidence: piolium/findings/p8-006-webhook-type-coercion/

p8-007 Webhook replay via missing event ID deduplication

  • Severity: MEDIUM
  • Summary: Stripe webhook handler has no event ID deduplication for most event types (invoice.paid, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted). Only checkout.session.completed has partial protection via onConflictDoNothing().
  • Impact: Replay of customer.subscription.updated changes user tier, invoice.paid re-activates canceled subscriptions, customer.subscription.deleted cancels active subscriptions (DoS). Requires STRIPE_WEBHOOK_SECRET for forgery.
  • Root Cause: The webhook handler at web/src/routes/api/stripe/webhook.ts:18-21 processes events without tracking event.id. The updateSubscriptionInDB() function has no idempotency check.
  • Key Code Reference: web/src/routes/api/stripe/webhook.ts:18-21 (entry point), web/src/server/services/billing.service.ts:142-223 (handler)
  • PoC Status: pending
  • Detailed Report: piolium/findings/p8-007-webhook-replay/report.md
  • Proof of Concept: piolium/findings/p8-007-webhook-replay/draft.md
  • Evidence: piolium/findings/p8-007-webhook-replay/

p8-008 WebSocket JWT leakage via query parameter

  • Severity: MEDIUM
  • Summary: WebSocket server authenticates connections by extracting JWT from ?token= query parameter. JWTs are visible in server access logs, proxy logs, and browser network history, enabling connection hijacking via log compromise.
  • Impact: JWT token leakage through server logs enables WebSocket connection hijacking for any user whose token appears in logs. Attacker gains read-only access to real-time alerts (darkwatch exposures, voiceprint alerts, spam notifications).
  • Root Cause: The getTokenFromRequest() function at web/src/server/websocket.ts:39-43 extracts the JWT via url.searchParams.get("token"). Query parameters are logged by virtually all reverse proxies, load balancers, and log aggregation systems.
  • Key Code Reference: web/src/server/websocket.ts:39-43 (token extraction), web/src/server/websocket.ts:56-67 (authentication)
  • PoC Status: pending
  • Detailed Report: piolium/findings/p8-008-websocket-jwt-query-param/report.md
  • Proof of Concept: piolium/findings/p8-008-websocket-jwt-query-param/draft.md
  • Evidence: piolium/findings/p8-008-websocket-jwt-query-param/

p8-009 WebSocket no Origin header validation

  • Severity: MEDIUM
  • Summary: WebSocket server on port 3001 does not validate the Origin header during the HTTP upgrade handshake. Combined with JWT-in-query-parameter (p8-008), any website can initiate WebSocket connections using stolen tokens.
  • Impact: Cross-origin WebSocket connections without Origin validation. Combined with JWT-in-query-parameter (p8-008), this creates a complete authentication bypass chain accessible from any website.
  • Root Cause: The WebSocket connection handler at web/src/server/websocket.ts:80-102 calls authenticateConnection() but never inspects req.origin or uses verifyClient on the WebSocketServer constructor. CORS middleware at port 3000 does not apply to the WebSocket server on port 3001.
  • Key Code Reference: web/src/server/websocket.ts:80-102 (connection handler), web/src/server/websocket.ts:56-67 (authentication)
  • PoC Status: pending
  • Detailed Report: piolium/findings/p8-009-websocket-no-origin-validation/report.md
  • Proof of Concept: piolium/findings/p8-009-websocket-no-origin-validation/draft.md
  • Evidence: piolium/findings/p8-009-websocket-no-origin-validation/

p8-010 VoicePrint resource exhaustion via unbounded audio upload

  • Severity: MEDIUM
  • Summary: VoicePrint audio endpoints accept unbounded base64 payloads with no maximum length. A 100MB base64 payload consumes 300MB+ memory per request. The protectedProcedure rate limit (100/min) is insufficient for large payloads.
  • Impact: Memory exhaustion (30GB+ across 100 rapid requests → OOM kill), disk exhaustion, ML model resource exhaustion, and service disruption for all users on the same server.
  • Root Cause: The AnalyzeAudioSchema at web/src/server/api/schemas/voiceprint.ts:8-10 uses string([minLength(1)]) with no maxLength. The analyzeAudio() service at web/src/server/services/voiceprint.service.ts:135-140 decodes the entire base64 payload into memory without size validation.
  • Key Code Reference: web/src/server/api/schemas/voiceprint.ts:8-10 (schemas), web/src/server/services/voiceprint.service.ts:135-140 (service), web/src/server/api/utils.ts:23-28 (protectedProcedure)
  • PoC Status: pending
  • Detailed Report: piolium/findings/p8-010-voiceprint-resource-exhaustion/report.md
  • Proof of Concept: piolium/findings/p8-010-voiceprint-resource-exhaustion/draft.md
  • Evidence: piolium/findings/p8-010-voiceprint-resource-exhaustion/

p8-011 Browser extension vulnerable dependency (superjson CVE-2022-23631)

  • Severity: MEDIUM
  • Summary: Browser extension depends on superjson@^2.2.1, which allows versions 2.2.12.2.5 affected by CVE-2022-23631 (CVSS 10.0 prototype pollution → RCE). The web server is NOT affected (does not use superjson).
  • Impact: Prototype pollution in the browser extension context only. Could affect extension storage, API keys, and local data. The web server is not at risk since superjson is not installed there.
  • Root Cause: browser-ext/package.json:18 declares "superjson": "^2.2.1". The caret range allows 2.2.1 through 2.2.5. CVE-2022-23631 was fixed in superjson 2.2.6.
  • Key Code Reference: browser-ext/package.json:18 (dependency declaration), browser-ext/src/lib/api-client.ts (tRPC client using superjson)
  • PoC Status: pending
  • Detailed Report: piolium/findings/p8-011-superjson-vulnerable-version/report.md
  • Proof of Concept: piolium/findings/p8-011-superjson-vulnerable-version/draft.md
  • Evidence: piolium/findings/p8-011-superjson-vulnerable-version/

Attack Surface Summary

Trust Boundaries Assessed

Boundary Protocol Key Findings
TB-1: Internet → Web (SolidStart) HTTPS/tRPC p8-001 (XSS), p8-004 (rate limit), p8-005 (CORS)
TB-2: tRPC → Drizzle ORM libSQL/Turso No active SQL injection found (CVE-2026-39356 surface noted but safe)
TB-3: tRPC → Stripe HTTPS p8-003 (open redirect), p8-006 (type coercion), p8-007 (replay)
TB-4: tRPC → External APIs HTTPS p8-004 (rate limit bypass on darkwatch scans)
TB-5: WebSocket → ws WSS (port 3001) p8-008 (JWT leak), p8-009 (no Origin)
TB-6: Browser Extension → tRPC HTTPS p8-011 (superjson CVE)
TB-8: Puppeteer → File System Local p8-002 (SSRF)
TB-9: tRPC → VoicePrint Storage Local FS p8-010 (resource exhaustion)

Threat Model Scenarios Mapped to Findings

Scenario Finding Status
AS-1: SQL Injection via Drizzle ORM Not confirmed CVE-2026-39356 surface noted; no active injection found
AS-2: Prototype Pollution via superjson p8-011 Confirmed in browser extension only
AS-3: WebSocket Authentication Bypass p8-008, p8-009 Confirmed (combined chain)
AS-4: Admin Privilege Escalation Not confirmed p4-001 (role mutation) rejected as low severity
AS-5: Rate Limit Bypass p8-004 Confirmed
AS-6: Stripe Webhook Replay p8-006, p8-007 Confirmed (two independent issues)
AS-7: XSS via SolidJS JSX p8-001 Confirmed
AS-8: Path Traversal via Puppeteer p8-002 Confirmed as SSRF (not path traversal)

Domain Attack Research Coverage

Domain Findings Coverage
VoicePrint (ML/AI) p8-010 Partial — resource exhaustion found; model poisoning not assessed
DarkWatch (OSINT) p8-004 Partial — rate limit bypass found; SSRF via URL manipulation not confirmed
Stripe (Payments) p8-003, p8-006, p8-007 Good — three independent findings
WebSocket (Real-time) p8-008, p8-009 Good — two complementary findings
File System (Puppeteer + Audio) p8-002, p8-010 Partial — SSRF and resource exhaustion found

Coverage Gaps

Areas Not Fully Assessed

Area Reason Impact
iOS native app SwiftUI codebase not explored in depth Certificate pinning, keychain storage, root detection not assessed
Android native app Kotlin/Compose codebase not explored in depth Certificate pinning, keystore storage, root detection not assessed
Drizzle ORM SQL injection (CVE-2026-39356) No actively exploitable injection found, but no exhaustive analysis performed Latent risk — if sql<> tag usage changes, injection could become possible
Admin SQL pattern (p4-012) Rejected as low severity — current sql<> usage is safe Latent risk if future code introduces dynamic column names
CI/CD pipelines Standard workflows only, no AI agent integrations Supply chain attack surface assessed as low
Docker image base (node:22-alpine) No CVE scan of base image Supply chain risk not fully assessed
Backup procedures Referenced in docs/BACKUPS.md but not reviewed Data integrity and recovery not assessed

Known False-Positive Sources

  1. Vite server.fs.deny bypasses — These affect the dev server only. Production deployment uses vite build + vite start (Nitro).
  2. superjson prototype pollution (server-side) — The web server does NOT use superjson. Only the browser extension is affected (reported as p8-011).
  3. jose JWE resource exhaustion — Project uses HS256 JWTs, not JWE. CVE-2024-28176 is not applicable.
  4. @trpc/server experimental_nextAppDirCaller — Next.js-specific feature not used in SolidStart.

Methodology Notes

Review Chamber Activity

  • Chambers spawned: 1 (balanced mode)
  • Draft findings evaluated: 19 (12 from p4 SAST phase, 7 from l4 probe phase)
  • Valid findings promoted: 11 (1 HIGH, 10 MEDIUM)
  • Rejected (false positive): 1 — p4-005 path-traversal-audio-storage (userId from ctx.user.id, not user input)
  • Rejected (low severity): 3 — p4-001 unvalidated-role-mutation, p4-012 admin-sql-pattern, l4-007 extension-noop-endpoints
  • Rejected (duplicate): 4 — l4-002, l4-004, l4-005, l4-006 collapsed into parent findings
  • Attack patterns added to registry: 11 (AP-001 through AP-011)

Attack Patterns Identified

Pattern Root Cause Severity
AP-001 Stored XSS via innerHTML + unsanitized markdown HIGH
AP-002 Puppeteer SSRF via --no-sandbox + page.setContent() MEDIUM
AP-003 Open redirect via unvalidated return URL MEDIUM
AP-004 Rate limit bypass via incomplete sensitive path list MEDIUM
AP-005 CORS origin from unvalidated env var MEDIUM
AP-006 Webhook type coercion via chained as unknown as MEDIUM
AP-007 Webhook replay via missing event ID deduplication MEDIUM
AP-008 JWT in WebSocket query parameter MEDIUM
AP-009 WebSocket no Origin validation MEDIUM
AP-010 Unbounded input → resource exhaustion MEDIUM
AP-011 Vulnerable dependency version (superjson) MEDIUM

Process Notes

  • L6 consolidation was skipped — Findings were validated in the chamber (L5) but the L6 consolidation step that promotes findings to piolium/findings/ was not executed. This report was assembled from the validated p8 draft findings in piolium/findings-draft/.
  • No PoC scripts were constructed — All findings have PoC-Status: pending. The chamber deemed PoC investment unnecessary for the MEDIUM-severity findings, and the HIGH-severity XSS finding (p8-001) was confirmed via code analysis alone.
  • No variant findings — No findings with metadata.json containing "is_variant": true were identified.

Conclusion

Kordant demonstrates a reasonable security foundation with Clerk-managed authentication, Stripe integration with signature verification, and a structured tRPC procedure hierarchy with authz middleware. However, the audit revealed systematic input validation gaps — missing maximum-length constraints, incomplete path-based rate limiting, and absent Origin validation — that suggest the input validation strategy needs architectural review rather than isolated fixes.

The highest-impact finding (p8-001, stored XSS) requires admin access for payload creation but has automatic victim-side execution. The most architecturally concerning finding (p8-008 + p8-009, WebSocket authentication) represents a complete authentication bypass chain when combined. The most operationally impactful findings (p8-004, p8-010) enable resource exhaustion and cost abuse that affect all users.

Priority recommendations:

  1. p8-001 (HIGH): Implement HTML sanitization (e.g., DOMPurify) in the blog post renderer; replace innerHTML with text-safe rendering.
  2. p8-008 + p8-009 (MEDIUM): Move JWT from query parameter to Authorization header; add verifyClient with Origin validation on the WebSocket server.
  3. p8-003 (MEDIUM): Add domain allowlisting to returnUrl validation (e.g., restrict to *.kordant.com and configured domains).
  4. p8-004 (MEDIUM): Replace substring matching with exact procedure name matching; expand sensitive operation list.
  5. p8-006 + p8-007 (MEDIUM): Add field-level validation before DB writes; implement event ID deduplication table.
  6. p8-010 (MEDIUM): Add maxLength constraint to audio schemas; implement request body size limits.
  7. p8-011 (MEDIUM): Update browser extension superjson dependency to ^2.2.6 or later.

Report generated by piolium Phase L6c (Final Report Assembly)
Audit ID: 2026-05-28T13:00:30.320Z | Mode: balanced | Commit: 26d9f8b