Skip to main content

Command Palette

Search for a command to run...

Web Application Penetration Test Report

Client: [CLIENT] Target: [target-domain.com] / [app.target-domain.com] Engagement Period: July 16, 2026 – August 28, 2026 Methodology: OWASP Web Security Testing Guide (WSTG) Author: Dave Tiedemann Report Date: September 7, 2026 Classification: Confidential — For authorized recipients only

Updated
•40 min read•View as Markdown
0
30 years in creative leadership (in the ad industry as a Creative Director) taught me one core skill: dissecting complex systems and finding the flaw before someone else does. My tech roots run back to 8-bit machine code on a C64 (yeah – 80s, baby!) Today, I’m combining that legacy hacker curiosity with modern threat detection and offensive security. BATTLE-TESTED LOGIC: Decades of high-pressure problem solving and systems analysis. HACKER MINDSET: I don't just follow playbooks; I reverse-engineer how the attack path actually works. CONTINOUS SKILL-UP: CompTIA Security+ certified (among other certs), building home labs, and constantly training for L1 ops to future Pentesting.

Table of Contents

  1. Executive Summary

  2. Authorization & Scope

  3. Methodology

  4. Engagement Overview

  5. Assignment 1 — Integration Architecture & Surface Security (iFrame Review)

  6. Assignment 2 — Authenticated Testing: Pending Partner Account

  7. Assignment 3 — Authenticated Application Testing (August 2026)

  8. Consolidated Findings Table

  9. Root Cause Analysis

  10. 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

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):

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:

{
  "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:

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:

<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:

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:

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.


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:

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).