# Web Application Penetration Test Report

## Table of Contents

1.  [Executive Summary](#executive-summary)
    
2.  [Authorization & Scope](#authorization--scope)
    
3.  [Methodology](#methodology)
    
4.  [Engagement Overview](#engagement-overview)
    
5.  [Assignment 1 — Integration Architecture & Surface Security (iFrame Review)](#assignment-1)
    
6.  [Assignment 2 — Authenticated Testing: Pending Partner Account](#assignment-2)
    
7.  [Assignment 3 — Authenticated Application Testing (August 2026)](#assignment-3)
    
    *   7.1 [Authentication & Session Management](#71-authentication--session-management)
        
    *   7.2 [Authorization, Mass Assignment & Privilege Escalation](#72-authorization-mass-assignment--privilege-escalation)
        
    *   7.3 [Cross-Role Data Leak Testing — Expert ↔ Startup](#73-cross-role-data-leak-testing--expert--startup)
        
8.  [Consolidated Findings Table](#consolidated-findings-table)
    
9.  [Root Cause Analysis](#root-cause-analysis)
    
10.  [Remediation Roadmap](#remediation-roadmap)
     

* * *

## Executive Summary

This report documents three sequential penetration testing assignments conducted against the \[CLIENT\] web application (`[target-domain.com]` and `[app.target-domain.com]`) between July 16 and August 28, 2026. The engagement progressed from a black-box surface review (Assignment 1) through authenticated single-role testing (Assignment 2) to full multi-role, cross-role authorization testing (Assignment 3). All testing was performed with written authorization under an agreed scope and rules of engagement.

**Total findings: 27** across all engagements, ranging from Informational to Critical severity.

### Critical findings (4)

The most severe findings all share a common class of root cause — serialization and access-control logic that does not account for the requesting user's role or verification state:

*   **SEC-02** — The partner startups-listing endpoint returns real PII (names, emails, phone numbers, verification/KYC data, signed pitch-deck download URLs) for all startups in the platform to any authenticated partner, regardless of relationship.
    
*   **SEC-03** — The "\[AI Analysis Feature\]" AI feature's free-use quota is enforced exclusively client-side via `localStorage`, trivially bypassed, enabling unlimited use of a paid LLM-backed endpoint with no rate-limiting or server-side tracking.
    
*   **SEC-08** — An unverified/pending partner can self-approve their own account status via mass assignment on the profile-edit endpoint (`PUT /api/users/users/me`), immediately unlocking gated partner features and real other-users' business data.
    
*   **SEC-10** — The same mass-assignment self-approval path applies to business-role accounts: an unverified startup can set their own `verification.status` to `"verified"` via a direct profile PUT.
    
*   **SEC-11** — The published projects bulk listing returns DocuSign contract signing URLs, expert full names and user IDs, Firebase internal document paths, and Google Drive folder links to any authenticated expert, regardless of whether they are a party to those projects.
    

### High findings (3)

*   **F-11** — `[app.target-domain.com]` (account creation and login) has no CSP, no `X-Frame-Options`, and no HSTS, making the authentication surface fully embeddable in an attacker-controlled frame.
    
*   **F-04** — The backend AI recommendation API (`POST /api/recommendation/scalemap`) accepts requests with no authentication and no rate-limiting, creating a direct cost-abuse and resource-exhaustion vector.
    
*   **SEC-09** — The business account legal-authorization gate self-clears after a single failed publish attempt, requiring no explicit user confirmation; effectively allowing any business account to bypass the legal binding-authority check by sending the publish request twice.
    

### Key architectural observation

Findings in Assignments 1 and 3 share the same underlying root cause: the application's serializers return full internal documents without filtering based on who is requesting them. Whether it is a bulk project listing exposing DocuSign URLs to unrelated experts, a bulk startup listing exposing PII to any partner, or profile-edit endpoints that echo unvalidated fields — the pattern is consistent. A viewer-aware serializer layer applied uniformly across the API would close the largest class of open findings at once.

* * *

## Authorization & Scope

**Written authorization:** Confirmed for `[target-domain.com]` and `[app.target-domain.com]` per the engagement brief and signed Rules of Engagement. All testing performed within authorized scope.

**Engagement type by assignment:**

| Assignment | Type | Auth level |
| --- | --- | --- |
| 1 | Black-box / guest access | No credentials — anonymous flow only |
| 2 | Grey-box | Authenticated pending/unverified partner account |
| 3 | Grey-box | Multiple role-specific test accounts (startup, expert, partner variants) |

**Out of scope (all assignments):**

*   `framer.com`, `framerusercontent.com`, `framer.app` (Framer platform infrastructure)
    
*   `googletagmanager.com`, `google-analytics.com`, `doubleclick.net` (third-party analytics/ad infrastructure)
    
*   Third-party vendor backends (only client-side scripts loaded on target pages were reviewed)
    
*   DoS/availability testing
    
*   Social engineering
    

**Subdomains identified during engagement:**

*   `[app.target-domain.com]` — primary application (tested)
    
*   `[payment.target-domain.com]` — subscriptions/Stripe API (discovered, not yet tested — **SEC-07**)
    
*   `[auth.target-domain.com]` — Firebase Auth action handler (discovered, inventory only — **SEC-07**)
    

**Rules of engagement followed throughout:** passive/manual testing; no automated scanning or fuzzing tools beyond controlled Burp Intruder sessions; no real user data accessed beyond what appeared in API responses; all test submissions used clearly-marked synthetic test data.

* * *

## Methodology

All testing follows the **OWASP Web Security Testing Guide (WSTG)** structure. Each phase produced captured evidence (request/response pairs, screenshots); findings are referenced to OWASP WSTG test IDs and CWE numbers.

**Tooling:**

*   Burp Suite Community — primary proxy, Repeater for manual request replay, HTTP History for traffic capture
    
*   Browser DevTools — JavaScript inspection, DOM analysis, browser storage inspection (`localStorage`, `sessionStorage`)
    
*   `curl` — confirmatory header checks
    
*   PoC HTML files — clickjacking confirmation
    

**Test phases covered across the engagement:**

1.  Configuration & Deployment (headers, CORS, TLS, exposed paths)
    
2.  Identity Management & Authentication (registration, login, session, password reset, MFA)
    
3.  Authorization (IDOR/BOLA, mass assignment, privilege escalation, cross-role access)
    
4.  Session Management (token lifetime, logout revocation, CSRF)
    
5.  Input Validation (SQLi probe, client-side validation bypass)
    
6.  Business Logic (quota bypass, workflow ordering, side-effect exploitation)
    
7.  Client-Side Security (postMessage, browser storage, CSP)
    

* * *

## Engagement Overview

| Assignment | Period | Focus | Accounts |
| --- | --- | --- | --- |
| 1 | Jul 16–22, 2026 | Anonymous-flow surface audit, iFrame/CSP/clickjacking, AI API exposure | None (black-box) |
| 2 | Jul 23–24, 2026 | Authenticated pending partner — mass assignment, endpoint access, AI quota | \[Tester / Test Org\] (partner, pending) |
| 3 | Aug 14–28, 2026 | Auth/sessions, multi-role regression, cross-role data leak testing | \[Startup Test Account\] (startup), \[Expert Test Account\] (expert), \[Partner Test Account\] / \[Partner Test Account\] (partner), \[Business Test Account\] / \[Business Test Account\] (business-unverified) |

* * *

## Assignment 1

### Integration Architecture & Surface Security Assessment (iFrame / \[AI Assessment Tool\] Review)

**Engagement window:** 2026-07-16 to 2026-07-22  
**Methodology:** OWASP WSTG — black-box, anonymous flow

#### Scope clarification — no custom iframe

The original scope brief referred to testing an "iframe" on the `[target-domain.com]/#scalemap` page. Investigation confirmed that **\[AI Assessment Tool\] is not embedded via** `<iframe>` — it is an inline React component that calls `[app.target-domain.com]` directly via `fetch`. `document.querySelectorAll('iframe')` returned exactly one element: `iframe#__framer-editorbar` (a hidden Framer platform element), not a \[AI Assessment Tool\] iframe. The test scope was consequently redirected toward: (a) whether the parent pages themselves can be framed by external sites, and (b) whether the postMessage-based channel on the page is safe.

#### Data-flow diagram

```yaml
User/Browser
  └──[loads page]──► [target-domain.com]
                       (Framer-hosted, inline React [AI Assessment Tool] widget)
                         └──[POST /api/recommendation/scalemap, NO auth]──► [app.target-domain.com]
                                                                               (nginx/1.22.1 backend)
                                                                                 └──► AI/LLM service
                         └──[cached in cleartext]──► sessionStorage: scaleMapDraft
  └──[Log in / Sign Up, no CSP/XFO/HSTS]──► [app.target-domain.com]
  └──[third-party, out of scope]──► Google Tag Manager
  └──[third-party, NO origin check on receive]──► [third-party-tracking-vendor]/tracer.js
```

#### Required test areas — status

| # | Area | Status | Result |
| --- | --- | --- | --- |
| 1 | Integration architecture + data-flow | ✅ Done | No iframe; \[AI Assessment Tool\] is inline, calls `[app.target-domain.com]` directly |
| 2 | iframe HTML config / sandbox review | ✅ Done | No custom iframe — only Framer platform iframe (out of scope) |
| 3 | Content Security Policy (parent + application) | ✅ Done | Total absence on both `[target-domain.com]` and `[app.target-domain.com]` |
| 4 | Clickjacking / unauthorized embedding | ✅ Done | Confirmed vulnerable on both the marketing page (**F-01**) and the application/auth surface (**F-11**) |
| 5 | Parent–iframe (postMessage) communication | ✅ Done | No \[AI Assessment Tool\]-specific channel; a third-party script's listener has no origin validation (**F-10**) |
| 6 | Authentication, cookies, session | ✅ Done (anonymous flow) | No auth, no cookies used anonymously. Authenticated flow untested (no credentials this pass) |
| 7 | Browser storage & sensitive data | ✅ Done | Full wizard state cached in cleartext `sessionStorage` |
| (**F-05**) |  |  |  |

* * *

#### Assignment 1 Findings

| ID | Severity | OWASP / WSTG | CWE | Title |
| --- | --- | --- | --- | --- |
| F-11 | **Critical/High** | WSTG-CLNT-09, WSTG-CONF-07, WSTG-CONF-01 | CWE-1021, CWE-319 | `[app.target-domain.com]` has no CSP, no X-Frame-Options, no HSTS |
| F-04 | **High** | WSTG-ATHZ-01 | CWE-306 | Backend AI recommendation API has no authentication — unrestricted invocation |
| F-10 | **High** | WSTG-CLNT-13 | CWE-346, CWE-940 | Third-party visitor-tracking script: no origin check + wildcard postMessage; chains with **F-01**/**F-11** |
| F-01 | **Medium** | WSTG-CLNT-09 | CWE-1021 | `[target-domain.com]` (marketing page) has no clickjacking protection |
| F-05 | **Low-Medium** | WSTG-CLNT-11 | CWE-922 | Full wizard state + AI result cached in cleartext `sessionStorage` |
| F-02 | Info | WSTG-CONF-02 | N/A | No custom embedded iframe for \[AI Assessment Tool\] (architecture clarification) |
| F-03 | Info | N/A | N/A | Two incidental third-party iframes (Framer editor bar, GTM noscript) — out of scope |
| F-06 | Info (closed) | WSTG-CONF-07 | CWE-942 | CORS correctly allow-listed on backend API — not vulnerable (does not mitigate **F-04**) |
| F-07 | Info | WSTG-CONF-02 | CWE-200 | Server version disclosure (`nginx/1.22.1`) |
| F-08 | Info | N/A | N/A | Misleading validation-message copy on wizard Step 1 |
| F-09 | Info | WSTG-SESS-02 | N/A | No cookies in anonymous flow (clean); authenticated session cookies untested |

* * *

#### \[HIGH\] F-11 — `[app.target-domain.com]` has no CSP, no X-Frame-Options, no HSTS

**Endpoint:** `GET https://[app.target-domain.com]/` (applies to the whole subdomain, including `/LogIn` and `/signUp`)

**Description:** The application subdomain — where account creation and login actually happen — exposes a full 10-header response with no `Content-Security-Policy`, no `X-Frame-Options`, and no `Strict-Transport-Security`. The parent marketing domain sets HSTS; the more sensitive authentication subdomain does not.

**Observed response headers (partial):**

```plaintext
Server: nginx/1.22.1
Permissions-Policy: camera=(self), microphone=(self)
[X-Frame-Options: ABSENT]
[Content-Security-Policy: ABSENT]
[Strict-Transport-Security: ABSENT]
```

**Proof of concept:** A clickjacking PoC HTML file pointed at `https://[app.target-domain.com]/` confirmed the account-creation page renders fully and interactively inside an attacker-controlled frame. No refusal, no blank frame.

![[Pasted image 20260722124647_REDACTED.png]](https://cdn.hashnode.com/uploads/covers/6a51ed1a14f7277783e1034a/eea1cf56-c718-4f46-a644-8a9a8aa6ad17.png align="center")

![[Pasted image 20260716114157_REDACTED.png]](https://cdn.hashnode.com/uploads/covers/6a51ed1a14f7277783e1034a/4e79072f-75ad-4fd9-adcd-df22077cec1f.png align="center")

**Remediation:** Add `X-Frame-Options: DENY` and/or `Content-Security-Policy: frame-ancestors 'none'` plus `Strict-Transport-Security: max-age=31536000` to `[app.target-domain.com]`'s server/reverse-proxy config. Likely a single shared config point for the whole subdomain.

**Risk rating:** High — authentication surface fully embeddable, enabling UI-redress and credential theft via clickjacking. Compounds to higher severity when chained with F-10 (see below).

**Verification:** Re-run PoC after remediation. Confirm frame is blocked and `curl -I` shows new headers.

* * *

#### \[HIGH\] F-04 — Unauthenticated Backend AI Recommendation API

**Endpoint:** `POST https://[app.target-domain.com]/api/recommendation/scalemap`

**Description:** The endpoint — which calls an LLM per request (three distinct inputs produced three distinct, coherent outputs) — accepts requests with only a `Content-Type` header. No API key, bearer token, or session cookie is required or checked. No `429` rate-limit response was encountered across multiple rapid successive submissions.

**Request shape:**

```json
{
  "description": "<free text>",
  "selected_challenges": ["<challenge area(s)>"],
  "stage": "<company stage>",
  "location": "<region>",
  "industry_type": "<industry>"
}
```

**Observed behavior:** `200 OK` + complete AI-generated recommendation on every call, unlimited, no authentication challenge at any point.

**Remediation:** Add server-side rate-limiting (IP and/or per-session token); enforce the 1000-character `description` limit server-side (currently client-side only and trivially bypassed via direct API calls); consider a CAPTCHA/proof-of-work gate before final submission. See also SEC-03 (quota bypass) and SEC-04 (second undocumented AI endpoint).

**Risk rating:** High — realistic cost-abuse and resource-exhaustion vector against an LLM-backed endpoint; also an unassessed prompt-injection surface.

* * *

#### \[HIGH\] F-10 — Third-Party Visitor-Tracking Script: No Origin Check + Wildcard postMessage

**Source:** `[third-party-tracking-vendor]/assets/js/tracer.js`, loaded on `[target-domain.com]`

**Description:** `getEventListeners(window)` revealed three `message` event listeners. Two (Framer's own editor bridge) correctly validate origin before acting. The third, from the third-party visitor-tracking script, does not:

```js
function sendMessageToParent(msg) {
  window.parent.postMessage(msg, '*'); // wildcard target
}
bindEvent(window, 'message', function(e) {
  if (e.data == 'APE') {              // no e.origin check
    document.addEventListener("click", function(event) {
      if (!event.shiftKey) {
        event.preventDefault();
        event.stopPropagation();
        var elem = event.target;
        if (elem != null) {
          var qStr = generateQuerySelector(elem);
          sendMessageToParent(JSON.stringify({'caller':'vtevent01','selector':qStr}));
        }
      }
    });
  }
});
```

**(1)** The receiver checks only `e.data`, never `e.origin` — any window can trigger click-interception by sending `'APE'`. **(2)** The sender uses wildcard `'*'` instead of a specific origin (the code even comments that a safer alternative exists but isn't active in production).

**Attack chain with F-01/F-11:** Since both domains can be framed, an attacker frames either page, calls `frame.contentWindow.postMessage('APE', '*')`, and begins intercepting every click the victim makes — exfiltrating a CSS selector per click back to the attacker's window. This is materially worse than plain clickjacking: continuous, active click-data exfiltration with no user indication.

![[Pasted image 20260722111051_REDACTED.png]](https://cdn.hashnode.com/uploads/covers/6a51ed1a14f7277783e1034a/4b51031e-d6eb-4bcf-8eb8-8b8a471b5c1d.png align="center")

**Remediation:** Highest-leverage fix is remediating F-01/F-11 (breaks the chain regardless of vendor script). Additionally, evaluate whether the vendor can enable the origin-checked code path referenced in the comment, or whether the vendor relationship should be terminated entirely.

**Risk rating:** High when chained with F-01/F-11 (chain demonstrated feasible in this engagement).

* * *

#### \[MEDIUM\] F-01 — Marketing Page Has No Clickjacking Protection

**Endpoint:** `GET https://[target-domain.com]/`

**Description:** Full response header set (18 headers) confirmed twice — `X-Frame-Options` and `Content-Security-Policy` are completely absent. `Strict-Transport-Security` is present on the marketing page but absent on the application subdomain (F-11).

**Proof of concept:**

```html
<iframe src="https://[target-domain.com]/"></iframe>
```

Header, nav, hero content, and cookie-consent banner all rendered fully and interactively inside an attacker-controlled frame.

![[Pasted image 20260724111522 2.png]](https://cdn.hashnode.com/uploads/covers/6a51ed1a14f7277783e1034a/90456fb9-2df7-4315-96a5-7d504e0393c5.png align="center")

**Remediation:** Add `X-Frame-Options: DENY` and/or `Content-Security-Policy: frame-ancestors 'none'` in Framer's custom-headers site settings.

**Risk rating:** Medium standalone; High in combination with F-10.

* * *

#### \[LOW-MEDIUM\] F-05 — Cleartext Sensitive Data in sessionStorage

**Description:** Full wizard state — raw challenge description and complete AI recommendation — stored under `sessionStorage.scaleMapDraft` in plain JSON. Any script running in the page's origin (including third-party analytics scripts already loaded) can read this data, as could any future XSS payload. This sits in tension with the product's own "Protected review / No external sharing" copy.

**Proof of concept:**

```js
JSON.parse(sessionStorage.getItem('scaleMapDraft'))
// { step, form: { description, selectedChallenges, stage, location, industryType },
//   result: { our_recommendation, project_objectives, ... } }
```

**Remediation:** Avoid persisting raw description/result in `sessionStorage` beyond the current render, or scope to in-memory component state. If cross-reload persistence is a feature, use a short-lived server-issued reference ID instead.

**Risk rating:** Low-Medium — requires XSS or compromised third-party script to exploit directly. Severity rises with genuinely confidential real-user submissions, which the product explicitly solicits.

* * *

#### Informational Findings (Assignment 1)

**F-02** — No custom embedded iframe exists for \[AI Assessment Tool\]. Confirmed via `document.querySelectorAll('iframe')` — only `iframe#__framer-editorbar` (hidden Framer platform element) found.

**F-03** — Two incidental third-party iframes: GTM `<noscript>` fallback and Framer site-editor toolbar. Neither is \[target-domain.com\]'s own code; both correctly out of scope.

**F-06 (Closed)** — CORS correctly allow-listed. `Origin: https://evil.com` test returned `200 OK` with full response body but no `Access-Control-Allow-Origin` header — server allow-lists rather than reflecting blindly. Important caveat: CORS is a browser-enforced client-side control; it provides zero mitigation for F-04 since a non-browser request is not subject to CORS at all.

**F-07** — Server version disclosure: `Server: nginx/1.22.1` on all `[app.target-domain.com]` responses. Fix: `server_tokens off;` in nginx config.

**F-08** — Misleading copy on wizard Step 1: "Not sure what to add? You can continue" implies challenge-area selection is optional, but attempting to continue without one is blocked: "Please select at least one challenge area." UX inconsistency, not a security issue.

**F-09** — No cookies in anonymous flow (clean, stateless). Authenticated session cookies untested — requires test credentials for a follow-up grey-box pass.

* * *

## Assignment 2

### Authenticated Testing — Pending/Unverified Partner Account

**Engagement window:** 2026-07-23 to 2026-07-24  
**Methodology:** OWASP WSTG — grey-box, authenticated partner account  
**Test account:** \[Tester / Test Org\] (`[tester-email@redacted]`) — `partner_status: pending`, identity verified but not yet manually approved

#### Summary

This assignment tested `[app.target-domain.com]` from the perspective of an authenticated but still-pending partner account. Work covered the profile-edit endpoint (mass assignment probe), two partner-scoped listing endpoints, an injection probe against the startup-invite flow, and the authenticated "\[AI Analysis Feature\]" AI analysis feature.

A potential mass-assignment finding (PP-01) — pending partner apparently self-approving account status — did not survive next-day verification in this assignment; the values did not persist. This finding was later revisited and confirmed on fresh test accounts in Assignment 3 (SEC-08, SEC-10). The one confirmed substantive finding (PP-04) is the \[AI Analysis Feature\] AI feature hanging indefinitely and silently displaying static placeholder content as a real personalized result.

* * *

#### Assignment 2 Findings

| ID | Severity | OWASP / WSTG | CWE | Title |
| --- | --- | --- | --- | --- |
| PP-04 | **Medium** | WSTG-BUSL-07 | CWE-754 | "\[AI Analysis Feature\]" AI analysis hangs indefinitely; silent fallback to static, unrelated placeholder content |
| PP-01 | Informational | WSTG-BUSL-04 | CWE-915 | Profile-edit endpoint echoes but does not persist privilege/status fields |
| PP-02 | Informational | WSTG-ATHZ-01 | CWE-284 | Confirmed-correct access control on two partner endpoints (403 for unverified partner) |
| PP-03 | Informational (Closed) | WSTG-INPV-05 | CWE-89 | SQLi probe on startup-invite email field — correctly blocked by server-side validation |

* * *

#### \[MEDIUM\] PP-04 — "\[AI Analysis Feature\]" AI Analysis Hangs Indefinitely; Silent Static Fallback

**Endpoint:** `POST /api/recommendation/rag` (`[app.target-domain.com]/pathfinder`)

**Description:** The \[AI Analysis Feature\] wizard submits the user's business challenge text to this endpoint, which should return a personalized analysis and tailored recommended next steps.

Testing with a benign submitted description confirmed:

1.  **The request never received a response.** Burp HTTP history showed blank `Status`, `Length`, and `MIME type` columns — no response logged even for the original browser-issued call. Replaying in Repeater also returned nothing. Burp's Event Log confirmed genuine connection timeouts ("Timeout in transmission", "Timeout in communication with remote server"), not an active rejection. Observed live in-browser over 20+ minutes with the "Our suggestion" box frozen on "We are analyzing your challenge..."
    
2.  **Despite no analysis completing, the wizard displayed four specific, detailed recommendations** — regulatory compliance for autonomous robots, US market entry strategy, safety/data-privacy standards, technical validation (all agriculture/robotics-flavored). These have no relation to the submitted description (about repairing an electric stove). They match the wizard's own placeholder/example text almost verbatim. The UI gave no indication this was fallback content, not live AI output.
    
3.  **Relaunching the wizard resumed the same broken draft** rather than starting fresh — same frozen "analyzing" state, same static recommendations — consistent with the wizard persisting its draft client-side, same pattern as F-05 in Assignment 1.
    
4.  **The request carries no** `Authorization` **header at all**, unlike other in-app API calls (e.g. `GET /api/projects/v1/projects`) which correctly attach `Authorization: Bearer <JWT>`. Whether auth/quota is enforced could not be verified since the backend never responds.
    

Additional context: the same hang was observed on a separate occasion the prior week with a completely different benign input, pointing to an intermittent backend reliability issue rather than anything input-specific.

![[Pasted image 20260724112152 2.png]](https://cdn.hashnode.com/uploads/covers/6a51ed1a14f7277783e1034a/1b542e4b-89f8-4607-b4ab-ae8a7df34420.png align="center")

![[Pasted image 20260721125738_REDACTED.png]](https://cdn.hashnode.com/uploads/covers/6a51ed1a14f7277783e1034a/5b4a4f44-efd0-4d1e-961a-d3bd8c7eec49.png align="center")

**Impact:** A partner submitting a real, potentially sensitive business challenge receives generic, unrelated advice while believing it is personalized analysis. The "3 Free uses" quota counter may or may not decrement on a hung run — if it does, users are charged usage credits for a broken feature.

**Remediation:**

*Frontend/UX:*

*   Hard client-side timeout (30–60s) with explicit error/retry state — never freeze indefinitely
    
*   Do not render "Recommended Next Steps" unless the analysis call returned successfully
    
*   Let the user retry without losing their typed description; provide explicit draft-discard option
    

*Backend/reliability:*

*   Investigate server-side why the endpoint never responds (logs/APM for timeout, unhandled exception, or crash in RAG retrieval / LLM generation step)
    
*   Add server-side timeout with clean error response and retry-with-backoff; consider circuit breaker
    
*   Add monitoring/alerting on this route's success rate and latency distribution
    
*   Add the missing `Authorization: Bearer <JWT>` requirement; re-test quota and character-limit enforcement once the endpoint responds normally
    

**Risk rating:** Medium — functional/business-logic integrity issue on a paid, quota-gated core feature with realistic risk of users acting on fabricated advice.

* * *

#### \[INFORMATIONAL\] PP-01 — Profile-Edit Endpoint Echoes Unvalidated Fields

**Endpoint:** `PUT /api/users/users/me`

**Description:** Adding `"partnerStatus":"approved"`, `"partner_status":"approved"`, `"status":"approved"` to the profile-edit body caused the immediate response to reflect these values (normalized to `"active"`). Follow-up fetch the next day via in-app navigation confirmed `partnerStatus` was back to `"pending"` — the injected values had not persisted. Legitimate fields edited in the same request (`display_name`, `focus_areas`) did persist correctly.

**Note:** This verdict was reversed in Assignment 3 (**SEC-08**) when the same technique was re-tested on a fresh account and the values *did* persist. The account context and exact field set appears to affect persistence behavior — the fix described in **SEC-08** applies here retroactively.

**Remediation:** Return the actual persisted/stored user object (re-read from DB post-write) rather than merging the raw request body into the response. Separately, apply the fix from **SEC-08** to guard the status-family fields against client writes.

**Risk rating:** Informational in this assignment context; Critical when replicated in **SEC-08**.

* * *

#### \[INFORMATIONAL\] PP-02 — Correct Access Control on Partner Endpoints

`GET /api/users/users/search?profile_type=business&partner...` and `GET /api/users/users/partners/me/startups?view=my_startups...` both returned `403 Forbidden` for the unverified partner account. Access control working as intended.

* * *

#### \[INFORMATIONAL / CLOSED\] PP-03 — SQLi Probe on Startup-Invite Email Field — Correctly Blocked

Submitted `' OR 1=1 --` as invite email address, both through UI and direct API call. Server response:

```plaintext
HTTP/1.1 422 Unprocessable Entity
{
  "detail": [{"type": "value_error", "loc": ["body", "email"],
   "msg": "value is not a valid email address: An email address must have an @-sign.",
   "input": "' OR 1=1 --"}]
}
```

Correctly blocked by server-side email-format validation before reaching any data layer. The structured Pydantic/FastAPI error shape mildly discloses the technology stack (WSTG-ERRH-01 — low priority). Not a vulnerability.

* * *

## Assignment 3

### Authenticated Application Testing — August 2026

**Engagement window:** 2026-08-14 to 2026-08-28  
**Methodology:** OWASP WSTG — grey-box, multiple test accounts across startup, expert, and partner roles

This assignment covered three distinct testing areas: authentication and session management (Gap 3), multi-role regression and authorization/privilege escalation (Gap 4), and dedicated cross-role data leak testing between the expert and startup roles.

* * *

### 7.1 Authentication & Session Management

**Gap 3 findings — SEC-01 through SEC-07**

* * *

#### \[MEDIUM\] SEC-01 — Session Token Remains Valid After Logout

**Endpoint:** Firebase Bearer JWT, used across `[app.target-domain.com]`

**Description:** Logging out via the UI does not invalidate the Firebase ID token server-side. A token captured before logout continues to authenticate API requests until natural expiry (~1 hour). This means any session that was captured (via F-10's click-interception chain, via a compromised device, or via logs) remains exploitable for up to an hour after the legitimate user has logged out.

**Remediation:** Implement refresh-token revocation on logout (Firebase Auth's `revokeRefreshTokens`), combined with the already-short ID-token lifetime. Document the intended session-lifetime policy.

**Verification:** Log in, capture a valid Bearer token, log out via UI, replay token against a protected endpoint before natural expiry — should return 401 after fix.

* * *

#### \[CRITICAL\] SEC-02 — Excessive Data Exposure on Partner Startups-Listing Endpoint

**Endpoint:** `GET /api/users/users/partners/me/startups`

**Description:** The partner startups-listing endpoint returns the full internal user document for each matched startup — including real names, email addresses, phone numbers, verification/KYC data, Stripe subscription details, internal flags (`is_admin`, `is_suspended`), and working signed pitch-deck download URLs. This data is returned for every matched startup to any authenticated partner, regardless of whether there is any established relationship with those startups.

Live PII exposure confirmed in production. The response exposed data belonging to real platform users, not test accounts.

**Remediation:**

*   Define the minimum field set a partner actually needs (company name, industry, what they're seeking, submitted category) and strip everything else from this endpoint
    
*   `phone_number`, `email`, verification/KYC objects, internal flags, Stripe fields, `org_id`, and signed pitch-deck URLs must be omitted
    
*   If pitch-deck access is an intended partner feature, gate it behind its own explicit, audited action — not an embedded signed URL in a bulk listing
    
*   Blue Team: assess whether the exposure encountered during testing requires compliance/notification follow-up
    

**Verification:** Re-run as authenticated partner — confirm response no longer includes PII or internal fields for other users.

* * *

#### \[CRITICAL\] SEC-03 — \[AI Analysis Feature\] Free-Use Quota Enforced Client-Side Only

**Endpoint:** `localStorage` key `rp[AI Analysis Feature]Uses:<user_id>`; `POST /api/recommendation/rag`

**Description:** The "3 Free uses" limit on the \[AI Analysis Feature\] AI analysis feature is tracked exclusively in `localStorage`. Resetting this key in DevTools allows an unlimited number of additional submissions, confirmed end-to-end: a real submission went through and a project was created after the quota was reset client-side. The server never validated whether the quota was exhausted before processing the request.

This compounds the unauthenticated, unrate-limited AI endpoint finding (F-04 in Assignment 1) — this was the last remaining soft barrier to unlimited free use of a paid-LLM-backed feature.

**Remediation:** Move quota tracking and enforcement fully server-side, keyed to the authenticated account. Reject the request server-side once the limit is reached, independent of what the client sends. Implement together with the rate-limiting fix already tracked for the AI endpoints.

**Verification:** Reset `localStorage` counter as a low-privilege test account; attempt another analysis — should be rejected server-side regardless of client-displayed count.

* * *

#### \[HIGH\] SEC-04 — Undocumented Second AI Endpoint

**Endpoint:** `POST /api/recommendation/rag/targeted`

**Description:** A second AI recommendation endpoint was discovered during the \[AI Analysis Feature\] quota-bypass confirmatory test. This endpoint was missing from the original scope inventory entirely. It fires in the same flow as the two endpoints already confirmed unauthenticated and unrate-limited; treat as equally suspect until proven otherwise.

**Remediation:** Red team to run the same auth and rate-limit checks applied to `scalemap`/`rag` against this endpoint specifically. Apply identical protection once confirmed. Add to official endpoint inventory.

* * *

#### \[MEDIUM\] SEC-05 — Account Enumeration via Login Endpoint

**Endpoint:** Firebase Identity Toolkit `accounts:signInWithPassword`

**Description:** The login endpoint returns distinct error responses for "wrong password on valid email" vs "unknown email address," enabling an attacker to confirm whether a given email address has a registered account. This is a Firebase Identity Toolkit behavior addressable via a project configuration change.

**Remediation:** Enable "Email Enumeration Protection" in the Firebase/Identity Platform project settings — makes both error cases return the identical generic `INVALID_LOGIN_CREDENTIALS` response. This is a project-configuration change, not application code.

* * *

#### \[MEDIUM-HIGH\] SEC-06 — Account Enumeration via Password Reset

**Endpoint:** Firebase Identity Toolkit `accounts:sendOobCode`; also visible directly on the password-reset page

**Description:** The password-reset flow also leaks valid-vs-invalid email status — both at the API layer (distinct Firebase error codes) and at the UI layer, where the error state falls through to a generic login-form error message ("Incorrect email or password...") rather than a reset-specific neutral message. This enumeration is visible without any traffic interception, making it worse than SEC-05.

Positive finding: reset tokens confirmed correctly single-use — must not be regressed during the fix.

**Remediation:** Same Firebase "Email Enumeration Protection" setting as SEC-05 fixes the API-layer leak. Separately fix the frontend to give the reset flow its own error handling that always shows "If an account exists for this email, you'll receive a reset link" regardless of email validity.

* * *

#### \[MEDIUM\] SEC-07 — Undocumented Subdomains Missing from Endpoint Inventory

**Discovered:** `[payment.target-domain.com]` (subscriptions/Stripe API) and `[auth.target-domain.com]` (Firebase Auth action handler)

**Description:** Both subdomains were discovered incidentally during testing but were missing from the official endpoint inventory. `[payment.target-domain.com]` is payment/Stripe-adjacent and completely untested — a notable gap given payment flows carry PCI-DSS implications.

**Remediation:** Add both to the official inventory. Scope and test `[payment.target-domain.com]/v1/subscriptions/me` and any other endpoints on that subdomain. `[auth.target-domain.com]` needs inventory inclusion only.

* * *

### 7.2 Authorization, Mass Assignment & Privilege Escalation

**Multi-role regression and Gap 4 findings — SEC-08, SEC-09, SEC-10**

* * *

#### \[CRITICAL\] SEC-08 — Unverified Partner Self-Approves Account via Mass Assignment

**Endpoint:** `PUT /api/users/users/me`  
**Test account:** \[Partner Test Account\] / \[Partner Test Account\] (`[partner-test-account@redacted]`) — partner, pending/unverified

**Description:** The profile-edit endpoint correctly guards `is_admin` and `profile_type` mismatches (returns `400`), but does not guard the partner-status field family. Submitting a profile PUT with `"partner_status":"approved"` (or `"partnerStatus":"approved"`, `"status":"approved"`) results in persistence, with the server normalizing to `"active"`.

Persistence was confirmed via a completely independent page load — the app's own UI displayed a "Verified profile" banner for an account whose `verification.status` was still `"pending"` at the object level.

Confirmed downstream impact: the same account, which received `403 Forbidden` on `GET /api/users/users/partners/me/startups` and `GET /api/users/users/search?profile_type=business` while genuinely unverified, received `200 OK` with real data on both after self-escalation — three other real users' business profiles returned to an account that should still be pending manual approval.

**Remediation:** Extend the existing `PUT /api/users/users/me` allow-list to cover `partner_status`, `partnerStatus`, `status`, `is_authorized_to_bind_company`. Audit siblings in the same family (`approved_by`, `approved_at`, `code_active`). Partner-approval should ideally be a dedicated admin-only endpoint, not a byproduct of the general profile-edit path. Blue Team: reset affected test account status back to `pending`.

**Verification:** Repeat injection on a fresh unverified account; confirm non-persistence. Re-run `partners/me/startups` and `users/search?profile_type=business` against genuinely unverified account; confirm both return `403`.

* * *

#### \[HIGH\] SEC-09 — Business Account Legal-Authorization Gate Bypasses Itself on Retry

**Endpoint:** `POST /api/projects/v1/projects/{id}/publish`  
**Test account:** \[Business Test Account\] / \[Business Test Account\] (`[business-test-account@redacted]`) — business-unverified

**Description:** Publishing a project requires the user to have confirmed they are authorized to represent their company and enter into legally binding agreements (`is_authorized_to_bind_company: true`). When this flag is `false`, the first publish attempt correctly returns:

```plaintext
403 Forbidden
"You must confirm that you are authorized to represent your company and enter into legally binding agreements before publishing a project."
```

However, the `403` response itself triggers a server-side write that sets `is_authorized_to_bind_company: true` on the account as a side effect — confirmed by: (a) a second identical POST with an empty body immediately returning `200 OK`, and (b) the field showing as `true` in a subsequent `GET /api/users/users/me`.

The confirmation prompt is effectively illusory — the legal acknowledgment requirement is fulfilled by the act of attempting to publish, not by any explicit user action or UI confirmation.

**Remediation:** Separate the gate check from the state mutation. `is_authorized_to_bind_company` should only be set by a dedicated explicit confirmation endpoint (e.g. `POST /api/users/users/me/confirm-binding-authority`) called after the user actively submits a UI confirmation — not as a side effect of a rejected publish. The confirmation should be audited and logged (who confirmed, when, from what IP) given its legal weight.

**Verification:** Fresh business account with `is_authorized_to_bind_company: false` → send publish POST once → confirm `403` → confirm flag is still `false` in follow-up GET → send publish POST again without intermediate confirmation → should still return `403`.

* * *

#### \[CRITICAL\] SEC-10 — Unverified Business Account Self-Approves Verification Status

**Endpoint:** `PUT /api/users/users/me`  
**Test account:** \[Business Test Account\] / \[Business Test Account\] — business-unverified

**Description:** Same root cause and attack surface as SEC-08, now confirmed on the business role. Submitting a profile PUT with `"verification":{"status":"verified"}` returns `200 OK` with both `verification.status:"verified"` and `is_researcher_verified:true` persisted — the latter flipping from `false` to `true`.

The profile-edit endpoint guards `is_admin` and `profile_type` but does not guard the verification-state field family for business accounts.

**Remediation:** Same fix as SEC-08, extended to the business profile: `verification.status`, `verification.reviewer_id`, `verification.reviewed_at`, and `is_researcher_verified` must be read-only from the client's perspective. Verification state changes should only be possible through a dedicated admin-privileged endpoint. Blue Team: reset affected test account back to `"pending"` / `false`. Confirm no real non-test business accounts show signs of self-approval.

**Verification:** Repeat PUT injection on a fresh unverified business account; confirm rejection/non-persistence. Confirm same guard applies to the partner role (SEC-08 and this should be the same code path).

* * *

### 7.3 Cross-Role Data Leak Testing — Expert ↔ Startup

**Test accounts:**

*   **Startup:** \[Startup Test Account\] (`[startup-test-account@redacted]`, ID: `[STARTUP-UID-REDACTED]`)
    
*   **Expert:** \[Expert Test Account\] (`[expert-test-account@redacted]`, ID: `[EXPERT-UID-REDACTED]`)
    

**Methodology:** Each direction tested independently with a fresh session token. Findings logged from live proxy testing in Burp.

* * *

#### Direction 1 — Startup → Expert

The startup account was used to browse the expert marketplace and interact with expert profiles.

| # | Method | Endpoint | Status | Notes |
| --- | --- | --- | --- | --- |
| 1 | GET | `/api/users/users/search?profile_type=expert&limit=3` | 200 | `privacy_masked:true` on all results — no PII, no email/phone/internal flags. PASS |
| 2 | GET | `/api/users/users/[EXPERT-UID-REDACTED]` | 200 | Direct expert profile lookup — `privacy_masked:true`, professional detail fields null. PASS |
| 3 | GET | `/api/projects/v1/projects/proposals/project/{project_id}` | 200 | Startup (project owner) reads incoming proposals — expected, returns proposal list. Legitimate owner access. |

**Startup → Expert result:** No unauthorized data exposure found. The expert search and direct profile endpoints correctly apply `privacy_masked:true` for startup-role viewers, suppressing professional detail fields while allowing basic display fields.

* * *

#### Direction 2 — Expert → Startup

The expert account was used to browse published projects, apply, and interact with startup data.

| # | Method | Endpoint | Status | Notes |
| --- | --- | --- | --- | --- |
| 1 | POST | `/api/projects/v1/projects/{id}/apply` | 201 | Expert applies to startup's project — expected |
| 2 | GET | `/api/projects/v1/projects/published/page?limit=12` | 200 | 52KB bulk project listing — see SEC-11 |

* * *

#### IDOR / Cross-Role Action Tests

| # | Test | Endpoint | Token used | Target owned by | Result |
| --- | --- | --- | --- | --- | --- |
| CRT-01 | Expert reads startup's expert search results | `GET /api/users/users/search?profile_type=expert` | Startup | N/A | **PASS** — `privacy_masked:true`, no PII |
| CRT-02 | Expert reads proposals for startup's project | `GET /api/projects/v1/projects/proposals/project/{id}` | Expert | Startup | **PASS** — `403 "Only the project owner can view proposals"` |
| CRT-03a | Expert reads own proposal directly by ID | `GET /api/projects/v1/projects/proposals/{proposal_id}` | Expert | Expert | 405 — endpoint only accepts PATCH |
| CRT-03b | Startup modifies expert's proposal via PATCH | `PATCH /api/projects/v1/projects/proposals/{proposal_id}` | Startup | Expert | **PASS** — `403 "Only the proposal owner can update this proposal"` |
| CRT-03c | Startup self-accepts via PATCH `is_accepted:true` | `PATCH /api/projects/v1/projects/proposals/{proposal_id}` | Startup | Expert | **PASS** — `403 "Only the proposal owner can update this proposal"` |
| CRT-04 | Startup accepts proposal via accept endpoint | `PATCH .../proposals/{proposal_id}/accept` | Startup | Expert | **PASS (expected)** — 200 OK, startup is project owner |
| CRT-05 | Expert self-accepts their own proposal | `PATCH .../proposals/{proposal_id}/accept` | Expert | Expert | **PASS** — `403 "Only the project owner can accept this proposal"` |
| CRT-06 | Startup looks up expert by ID — `privacy_masked` check | `GET /api/users/users/search?ids={user_id}` | Startup | Expert | **FAIL (→ SEC-12)** — `privacy_masked:true` present but professional fields fully populated |
| CRT-07 | Expert reads published projects bulk listing | `GET /api/projects/v1/projects/published/page?limit=12` | Expert | All startups | **FAIL (→ SEC-11)** — DocuSign URLs, expert names+IDs, Firebase paths, Drive links exposed |
| CRT-08 | Expert reads their applied-to project by ID | `GET /api/projects/v1/projects/{id}` | Expert | Startup | **PASS (expected)** — 200 OK, expert is assigned member |
| CRT-09 | Expert modifies startup's project via PUT | `PUT /api/projects/v1/projects/{id}` | Expert | Startup | **PASS** — 405 Method Not Allowed |
| CRT-10 | Expert reads unrelated project | `GET /api/projects/v1/projects/{other_project_id}` | Expert | Different startup | **PASS** — `403 "You are not a member of this project"` |
| CRT-11 | Expert reads startup's user profile by ID | `GET /api/users/users/{startup_user_id}` | Expert | Startup | **PASS** — `403 "Missing permissions: profiles:read"` |

* * *

#### \[CRITICAL\] SEC-11 — Published Projects Bulk Listing Exposes DocuSign Signing URLs, Expert PII, and Internal User IDs

**Endpoint:** `GET /api/projects/v1/projects/published/page?limit=12`  
**Tested as:** \[Expert Test Account\] (expert account, not a party to most of the listed projects)

**Sub-findings:**

**SEC-11a — DocuSign Contract Signing URLs (Critical)**  
For projects with `contractStatus:"sent"`, the bulk listing includes `businessSignUrl` and `expertSignUrl` — DocuSign JWT signing URLs for legal contracts between specific startups and experts. These are accessible to any authenticated expert, regardless of whether they are a party to those contracts. Even if individual URLs have short expiry windows, embedding live signing URLs in a bulk listing endpoint is a data exposure by design.

**SEC-11b — Expert Full Names and User IDs via** `expert_matches` **Field (High)**  
The `expert_matches` array exposes AI-recommended expert profiles including full names (first name + surname), user IDs, locations, and services. This allows any expert to enumerate other experts' identities. Surnames are exposed here that are suppressed in the direct expert search endpoint (where only first names appear under `privacy_masked:true`).

**SEC-11c — Startup Owner User IDs via Firebase** `businessREF` **Paths (Medium)**  
Every project record includes `businessREF`, `users_assigned`, `chatMembersREF`, and `admin_projectList` fields containing Firebase Firestore document paths — which include the startup owner's internal user ID. Any expert can extract startup user IDs in bulk and use them for targeted profile lookups.

**SEC-11d — Google Drive Folder URLs in Bulk Listing (Medium)**  
Several project records include a `gdrive` field containing shared Google Drive folder links pointing to startup project documents. Depending on Drive sharing settings, these may grant any link-holder read access.

**Remediation:** Introduce a viewer-aware serializer layer that applies a different field set based on whether the requesting user is (a) the project owner, (b) an assigned expert on that project, or (c) an unrelated expert browsing the marketplace. Category (c) should receive only the fields needed to evaluate and apply: title, description, field, budget range, timeline, expertise required, region. All of `businessSignUrl`, `expertSignUrl`, `expert_matches`, `businessREF`, `ExpertREF`, `users_assigned`, `chatMembersREF`, `admin_projectList`, and `gdrive` must be excluded from the marketplace browsing view.

**Verification:** Re-run the same request as an expert with no relationship to the listed projects; confirm all flagged fields are absent.

* * *

#### \[LOW-MEDIUM\] SEC-12 — `privacy_masked` Flag Inconsistently Applied Between Search Endpoints

**Endpoints:**  
`GET /api/users/users/search?profile_type=expert` (generic search)  
`GET /api/users/users/search?ids={user_id}` (ID-specific lookup)

**Description:** Both endpoints return `privacy_masked:true` for expert profiles viewed with a startup-role token. However, field suppression is only applied on the generic search:

*   `?profile_type=expert` — correctly suppresses: `expertise`, `dd_fields`, `services_offered`, `can_help`, `years_experience`, `consulting_experience`, `is_full_profile` (all return empty/null)
    
*   `?ids={user_id}` — claims `privacy_masked:true` but returns all the above fields fully populated
    

The `privacy_masked` flag's presence without enforcement is misleading — it signals to the client that masking is active when it is not. The fields leaked are professional summary data (not email/phone), so severity is limited — but the inconsistency undermines the flag as a reliable signal across the API.

**Remediation:** Ensure the same field-suppression logic applied in the `profile_type` search code path is also applied in the `ids` lookup code path. Both should use a shared serializer method keyed on the viewer's role, not on which search variant was called.

**Verification:** Call `?ids={any_expert_id}` with a startup-role token; confirm `expertise`, `services_offered`, `years_experience`, `consulting_experience`, and `is_full_profile` are suppressed.

* * *

## Consolidated Findings Table

| ID | Severity | Title | Assignment |
| --- | --- | --- | --- |
| SEC-02 | **Critical** | Excessive PII exposure on partner startups-listing endpoint | 3 |
| SEC-03 | **Critical** | \[AI Analysis Feature\] free-use quota enforced client-side only | 3 |
| SEC-08 | **Critical** | Unverified partner self-approves account via mass assignment | 3 |
| SEC-10 | **Critical** | Unverified business self-approves verification status via mass assignment | 3 |
| SEC-11a | **Critical** | DocuSign signing URLs in bulk project listing accessible to any expert | 3 |
| F-11 | **High** | `app.[target-domain]` (login/signup) has no CSP, X-Frame-Options, or HSTS | 1 |
| F-04 | **High** | Backend AI recommendation API has no authentication or rate-limiting | 1 |
| F-10 | **High** | Third-party tracking script: no origin check + wildcard postMessage, chains with F-01/F-11 | 1 |
| SEC-04 | **High** | Undocumented second AI endpoint, presumed equally unprotected | 3 |
| SEC-09 | **High** | Business legal-authorization gate bypasses itself on retry (side-effect mutation) | 3 |
| SEC-11b | **High** | Expert full names + user IDs exposed in `expert_matches` field of bulk listing | 3 |
| F-01 | **Medium** | Marketing page has no clickjacking protection | 1 |
| PP-04 | **Medium** | \[AI Analysis Feature\] AI hangs indefinitely; silent static fallback displayed as real result | 2 |
| SEC-01 | **Medium** | Session token remains valid after logout (no server-side revocation) | 3 |
| SEC-05 | **Medium** | Account enumeration via login endpoint | 3 |
| SEC-06 | **Medium-High** | Account enumeration via password-reset flow (API and UI layers) | 3 |
| SEC-07 | **Medium** | Undocumented subdomains missing from endpoint inventory | 3 |
| SEC-11c | **Medium** | Startup user IDs exposed via Firebase document paths in bulk listing | 3 |
| SEC-11d | **Medium** | Google Drive folder URLs exposed in bulk project listing | 3 |
| F-05 | **Low-Medium** | Full wizard state cached in cleartext `sessionStorage` | 1 |
| SEC-12 | **Low-Medium** | `privacy_masked` flag inconsistently applied — ID-specific lookup returns full professional profile | 3 |
| F-07 | **Info** | Server version disclosure (`nginx/1.22.1`) | 1 |
| F-08 | **Info** | Misleading validation-message copy on wizard Step 1 | 1 |
| F-09 | **Info** | No cookies in anonymous flow; authenticated flow untested | 1 |
| PP-01 | **Info** | Profile-edit endpoint echoes unvalidated fields in response | 2 |
| PP-02 | **Info** | Correct access control on two partner endpoints (403 for unverified partner) | 2 |
| PP-03 | **Info (Closed)** | SQLi probe on startup-invite email field — correctly blocked | 2 |

* * *

## Root Cause Analysis

All Critical and High findings in this engagement trace to one of three root-cause patterns:

**Pattern A — Missing viewer-aware serialization (SEC-02, SEC-11a–d, SEC-12)**  
The application's serializers return the full internal document regardless of who is requesting it. The role of the requesting user — owner vs. assigned member vs. unrelated visitor — is not factored into what fields are included in the response. This single pattern is responsible for five of the six Critical findings and all four Medium sub-findings in the bulk listing exposure cluster.

*Fix:* Introduce a shared serializer method keyed on `(viewer_role, viewer_relationship_to_object)`. Viewer role is already present in the JWT payload; relationship lookup can be derived from membership fields on the object (e.g. `businessREF`, `ExpertREF`, `users_assigned`).

**Pattern B — Missing server-side field guards on profile-edit (SEC-08, SEC-10, PP-01)**  
`PUT /api/users/users/me` correctly guards `is_admin` and `profile_type` but does not guard the approval-status and verification-state field families. The allow-list is incomplete. The same code path handles all role types, so one incomplete guard means two confirmed exploitable role paths (partner and business) and likely others.

*Fix:* Define and enforce a strict write-allow-list that explicitly excludes all fields representing admin-set approval or verification state. These fields should only be writable through dedicated admin-privileged endpoints.

**Pattern C — Missing server-side enforcement of client-facing limits (SEC-03, F-04, SEC-09)**  
Multiple quota and authorization checks that are represented in the UI are only enforced client-side or are absent at the API layer entirely: the \[AI Analysis Feature\] free-use counter lives in `localStorage`, the \[AI Assessment Tool\] API requires no authentication, and the legal-authorization publish gate self-clears as a side-effect of the 403 response body. In each case the server processes the request beyond the point where it should have stopped.

*Fix:* Any limit or gate that has business, legal, or cost significance must be enforced server-side, independently of what the client sends. Client-side enforcement is a UX convenience, not a security control.

* * *

## Remediation Roadmap

### Immediate (0–30 days)

| Priority | Finding(s) | Action | Owner |
| --- | --- | --- | --- |
| 1 | SEC-08, SEC-10 | Extend `PUT /api/users/users/me` allow-list to cover all approval/verification-state fields; audit siblings (`approved_by`, `approved_at`, `code_active`) | Backend |
| 2 | SEC-11a–d | Introduce viewer-aware serializer for the published projects bulk listing endpoint; exclude contract URLs, expert\_matches, Firebase paths, Drive links for unrelated viewers | Backend |
| 3 | SEC-02 | Define minimum field set for partner-role responses on the startups listing endpoint; strip PII and internal fields | Backend |
| 4 | F-01 + F-11 | Add `X-Frame-Options: DENY` and `Content-Security-Policy: frame-ancestors 'none'` to both domains (likely one shared config fix; also independently breaks F-10 chain) | Backend/DevOps |
| 5 | SEC-03 | Move \[AI Analysis Feature\] free-use quota tracking and enforcement fully server-side | Backend |
| 6 | SEC-09 | Separate the publish-gate check from the `is_authorized_to_bind_company` state mutation; create a dedicated explicit confirmation endpoint | Backend |
| 7 | F-04 | Add server-side rate-limiting and authentication requirement to all AI recommendation endpoints | Backend |

### Near-term (30–60 days)

| Priority | Finding(s) | Action | Owner |
| --- | --- | --- | --- |
| 8 | SEC-05, SEC-06 | Enable Firebase Email Enumeration Protection; fix password-reset UI error handling | Backend/Firebase owner |
| 9 | SEC-12 | Apply same field-suppression logic from `profile_type` search to the `ids` lookup endpoint; use shared serializer method | Backend |
| 10 | PP-04 | Investigate and fix \[AI Analysis Feature\] `rag` endpoint hang; remove silent static fallback; add client-side timeout with explicit error state | Backend + Frontend |
| 11 | SEC-01 | Implement refresh-token revocation on logout | Backend |
| 12 | F-10 | Evaluate third-party tracking vendor; request origin-checked code path or replace vendor | Frontend |
| 13 | SEC-04 | Scope and test `rag/targeted` endpoint; apply same protection as `scalemap`/`rag` | Red Team → Backend |
| 14 | F-05 | Remove cleartext wizard state from `sessionStorage`; use in-memory state or server-issued reference ID | Frontend |
| 15 | F-07 | `server_tokens off;` in nginx config | Backend/DevOps |

### Longer-term (60–90 days)

| Priority | Finding(s) | Action | Owner |
| --- | --- | --- | --- |
| 16 | SEC-07 | Scope and test `[payment.target-domain.com]` endpoints; add both discovered subdomains to inventory | Red Team |
| 17 | F-09 | Schedule a grey-box follow-up pass on the authenticated login/signup session cookies (Secure, HttpOnly, SameSite, rotation, invalidation) with test credentials | Red Team |
| 18 | — | Review file-upload handling in the \[AI Assessment Tool\] wizard (type/size validation, storage, access control) | Backend |
| 19 | — | Controlled prompt-injection assessment of the `description` field across all AI recommendation endpoints | Red Team |

* * *

*End of report.*  
*Total findings: 27 (5 Critical, 6 High, 8 Medium/Medium-High, 3 Low-Medium, 5 Informational).*
