25 KiB
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 SolidJSinnerHTMLdirective, 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 atweb/src/routes/blog/[slug].tsx:14-46performs raw string interpolation (html += \${line.slice(3)}
`) without HTML escaping. TheinnerHTML` 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-sandboxflag andpage.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-sandboxflag disables Chrome sandboxing, significantly expanding the attack surface. - Root Cause: The
generatePDF()function atweb/src/server/services/reports/generator.ts:141-150launches Puppeteer with--no-sandboxand usespage.setContent(html)with HTML compiled from database data viacompileData(). 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
returnUrlusing valibot'surl()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:
CreateCheckoutSessionSchemaatweb/src/server/api/schemas/billing.ts:4-6usesstring([url()])for returnUrl validation, which only validates URL format. The URL is passed directly tostripe.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 likedarkwatch.runScanandvoiceprint.analyzeAudioget the standard authenticated tier (100/min) instead of stricter sensitive tier (3/hr). - Impact: Resource exhaustion and cost abuse for sensitive operations.
darkwatch.runScancan trigger 100 external API calls per minute (HIBP, SecurityTrails, Censys, Shodan), andvoiceprint.analyzeAudiocan consume 300MB+ memory per request at high frequency. - Root Cause: The rate limiter at
web/src/server/api/utils.ts:35-38usessensitivePaths.some(p => path.includes(p))wheresensitivePathsis 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_URLas 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-30buildsallowedOriginsfrom["http://localhost:3000", "http://localhost:3001", process.env.APP_URL]without validating thatAPP_URLis 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 —undefinedvalues,NaNdates — 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, 207casts event data toRecord<string, unknown>, allowing any field type. When fields likecurrent_period_startareundefined, the cast tonumberproducesNaN, creatingInvalid Datevalues. - 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). Onlycheckout.session.completedhas partial protection viaonConflictDoNothing(). - Impact: Replay of
customer.subscription.updatedchanges user tier,invoice.paidre-activates canceled subscriptions,customer.subscription.deletedcancels active subscriptions (DoS). RequiresSTRIPE_WEBHOOK_SECRETfor forgery. - Root Cause: The webhook handler at
web/src/routes/api/stripe/webhook.ts:18-21processes events without trackingevent.id. TheupdateSubscriptionInDB()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 atweb/src/server/websocket.ts:39-43extracts the JWT viaurl.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-102callsauthenticateConnection()but never inspectsreq.originor usesverifyClienton theWebSocketServerconstructor. 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
protectedProcedurerate 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
AnalyzeAudioSchemaatweb/src/server/api/schemas/voiceprint.ts:8-10usesstring([minLength(1)])with nomaxLength. TheanalyzeAudio()service atweb/src/server/services/voiceprint.service.ts:135-140decodes 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.1–2.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:18declares"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
- Vite
server.fs.denybypasses — These affect the dev server only. Production deployment usesvite build+vite start(Nitro). - superjson prototype pollution (server-side) — The web server does NOT use superjson. Only the browser extension is affected (reported as p8-011).
- jose JWE resource exhaustion — Project uses HS256 JWTs, not JWE. CVE-2024-28176 is not applicable.
- @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 inpiolium/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.jsoncontaining"is_variant": truewere 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:
- p8-001 (HIGH): Implement HTML sanitization (e.g., DOMPurify) in the blog post renderer; replace
innerHTMLwith text-safe rendering. - p8-008 + p8-009 (MEDIUM): Move JWT from query parameter to
Authorizationheader; addverifyClientwith Origin validation on the WebSocket server. - p8-003 (MEDIUM): Add domain allowlisting to
returnUrlvalidation (e.g., restrict to*.kordant.comand configured domains). - p8-004 (MEDIUM): Replace substring matching with exact procedure name matching; expand sensitive operation list.
- p8-006 + p8-007 (MEDIUM): Add field-level validation before DB writes; implement event ID deduplication table.
- p8-010 (MEDIUM): Add
maxLengthconstraint to audio schemas; implement request body size limits. - p8-011 (MEDIUM): Update browser extension superjson dependency to
^2.2.6or later.
Report generated by piolium Phase L6c (Final Report Assembly)
Audit ID: 2026-05-28T13:00:30.320Z | Mode: balanced | Commit: 26d9f8b