the target and, where in scope, its subdomains were discovered from public sources, DNS and the application itself. pages, APIs, javascript and archived urls were mapped into an attack surface.
automated scanning
template-based scanners and fuzzers ran against the mapped surface to find known weaknesses and hidden content.
targeted testing
each part of the surface was tested request by request for injection, access control, authentication, server-side and configuration flaws, guided by hypotheses specific to this application.
business logic
workflows such as signup, payments and multi-step forms were tested for logic flaws that scanners do not find.
chaining
confirmed issues were combined where one enables another. a chain is reported only when it worked end to end.
verification
every finding was re-run against the live target before it was reported. a finding that could not be reproduced from its evidence was dropped.
04findings
F-001
critical
SSRF in webhook URL field exfiltrates cloud metadata credentials
severity
critical
cvss
9.3
affected
POST /api/integrations/webhooks
cwe
CWE-918
summary
The webhook configuration endpoint fetches the user-supplied URL server-side to deliver a verification ping. No URL validation is performed, so internal and link-local addresses are reachable from the application server. The endpoint reflects the response body to the caller, allowing exfiltration of IMDSv1 credentials, internal admin panels, and other intranet resources. We reproduced the bypass and retrieved a working set of temporary AWS credentials scoped to the `web-app-role` IAM role.
Credentials redacted in the sample. The live scan retrieved the full values and validated them against the cloud control plane (read-only call to STS GetCallerIdentity).
reproduction
01
Authenticate to the API as any user.
02
POST to /api/integrations/webhooks with body {"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/web-app-role"}.
03
Read the `verification_response` field. It contains the IMDSv1 credentials for the application's IAM role.
04
Validate the credentials with `aws sts get-caller-identity` (read-only).
recommended fix
Allowlist outbound hosts and reject private/link-local ranges before fetching, resolve DNS once and connect by the resolved IP (defeats DNS rebinding), and require IMDSv2 (hop-limit 1) so a bare GET cannot read role credentials. Return a boolean success only; never reflect the upstream body.
python
import ipaddress, socket
def resolve_public(host: str) -> str:
ip = ipaddress.ip_address(socket.gethostbyname(host))
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise ValueError('blocked: non-public address')
return str(ip) # connect to this IP, not the hostname
F-002
high
IDOR in invoice download lets any auth user read other tenants' invoices
severity
high
cvss
8.1
affected
GET /api/v1/invoices/:invoice_id
cwe
CWE-639
summary
The invoice retrieval endpoint accepts an invoice ID in the URL path but does not verify that the requesting user's tenant owns that invoice. Any authenticated user can iterate invoice IDs (sequential integers prefixed `inv_`) and retrieve invoice metadata (amount, line items, billing address, last-4 of payment method) for every customer in the system. We reproduced the bypass against three independent tenant pairs.
evidence
request
GET /api/v1/invoices/inv_4421 HTTP/1.1
Host: acme.dev
Authorization: Bearer <session-token-issued-to-tenant-a>
Session token belongs to tenant_a. Invoice inv_4421 belongs to tenant_b. Authorization was not checked.
reproduction
01
Authenticate as any user in tenant_a.
02
Issue GET /api/v1/invoices/inv_4421 (an invoice owned by tenant_b) with tenant_a's bearer token.
03
Observe a 200 response containing tenant_b's invoice metadata.
04
Iterate the invoice_id field - IDs are sequential and contiguous.
recommended fix
Authorize on the object, not just the route: load the record, then verify it belongs to the caller's tenant before returning anything. Add a cross-tenant integration test for every object endpoint, and move to unguessable ids (UUIDv7) as defense in depth, never as the check.
python
invoice = db.invoices.get(invoice_id)
if invoice is None or invoice.tenant_id != session.tenant_id:
return Response(status=403)
return invoice
F-003
high
Race condition in coupon application yields $0 checkout totals
severity
high
cvss
7.8
affected
POST /api/checkout/complete
cwe
CWE-362
summary
The checkout completion handler reads the cart total, applies the coupon, deducts inventory, and charges payment as separate non-atomic operations. Issuing multiple concurrent POSTs to /api/checkout/complete with the same cart and coupon causes coupon application to fire more than once per cart. With a 50% off coupon and three concurrent requests, the running total is reduced to a negative value, which is clamped to $0. The order is created, inventory is deducted, and the user is charged nothing.
Issue 5 concurrent POSTs to /api/checkout/complete with the same cart_id.
04
Observe that one or more orders are created with $0 totals.
recommended fix
Make checkout atomic: lock the cart row, apply the coupon inside the transaction with an idempotency guard on (cart_id, coupon_code), and charge the processor last (reverse the inventory deduction if the charge fails).
sql
BEGIN;
SELECT * FROM carts WHERE id = $1 FOR UPDATE;
INSERT INTO coupon_uses (cart_id, code) VALUES ($1, $2)
ON CONFLICT (cart_id, code) DO NOTHING; -- a second concurrent apply is a no-op
-- recompute total, charge the processor, then:
COMMIT;
F-004
medium
Stripe webhook signing secret exposed via .git directory
severity
medium
cvss
6.5
affected
GET /.git/config
cwe
CWE-538
summary
The application's deployment artifact includes a `.git/` directory, which is served by the production web server without restriction. The repository's `.git/config` exposes the remote URL, and pack-objects under `.git/objects/` allow full source recovery. We retrieved the project's `.env.production` file from the git history. It contains the Stripe webhook signing secret (`whsec_…`), the database password, and the JWT signing key. An attacker can use the webhook signing secret to forge payment webhooks and trigger arbitrary order fulfilment.
Reconstruction of repo via `git-dumper` succeeded. `.env.production` recovered from commit history. Stripe secret redacted in this sample.
reproduction
01
Fetch https://acme.dev/.git/config. Observe a 200 response with the repo config.
02
Run `git-dumper https://acme.dev/.git/ ./acme-platform` to reconstruct the repository.
03
Search git history for environment files (`git log --all -- '.env*'`).
04
Recover the Stripe webhook signing secret from `.env.production` at commit 4a7c91e.
05
Forge a `checkout.session.completed` event signed with the recovered secret and POST it to /api/webhooks/stripe. Observe the order marked paid without payment.
recommended fix
Block the directory at the edge, strip `.git/` from the deploy artifact, and rotate every secret it exposed (Stripe webhook secret, DB password, JWT key) while invalidating active sessions. Fail CI if `.env*` or `.git/` ships.
nginx
location ~ /\.git { deny all; return 404; }
F-005
medium
Stack traces and dependency versions leaked in 500 responses
severity
medium
cvss
5.3
affected
Multiple (any unhandled exception path)
cwe
CWE-209
summary
Unhandled server exceptions return HTTP 500 responses containing the full Python stack trace, source file paths revealing the deployment layout, and the exact versions of installed dependencies. The traces include framework internals (`/app/venv/lib/python3.11/site-packages/django/...`) and application module paths. The disclosed versions of `django`, `cryptography`, and `pillow` include CVEs published within the last 90 days. Stack traces also include local variable contents at the frame of failure, in some cases leaking partial request bodies.
HTTP/1.1 500 Internal Server Error
Content-Type: text/html
Server: gunicorn/21.2.0
Traceback (most recent call last):
File "/app/api/views/orgs.py", line 142, in invite
domain = email.split("@")[1].lower()
AttributeError: 'NoneType' object has no attribute 'split'
Local vars:
request = <Request POST /api/orgs/invite>
email = None
inviter_id = 'usr_4421'
Django 4.2.7 · Python 3.11.6 · cryptography 41.0.5
reproduction
01
Send any request that triggers an unhandled exception (passing null where a string is expected works on the invite endpoint).
02
Observe a 500 response containing the full Python traceback, source paths, dependency versions, and local variable contents.
recommended fix
Disable debug output in production: a global exception handler returns a generic 500 with an opaque reference id while full traces go to your observability backend. Fail CI on a debug build, and patch the disclosed dependency versions.
python
# settings/production.py
DEBUG = False
ALLOWED_HOSTS = ['acme.dev']
# handler500 logs the traceback server-side and renders a generic page
The password reset endpoint returns different responses for registered and unregistered email addresses. Registered emails receive a 200 with body `{"sent": true}`. Unregistered emails receive a 404 with body `{"error": "no such user"}`. The endpoint also has no rate limiting, so an attacker can iterate a list of emails and determine which ones are registered. We confirmed registration status for 12 emails from a public breach corpus in under 30 seconds.
evidence
request
POST /api/auth/password-reset HTTP/1.1
Host: acme.dev
Content-Type: application/json
{"email":"victim@example.com"}
response
HTTP/1.1 200 OK
Content-Type: application/json
{"sent": true}
- vs -
HTTP/1.1 404 Not Found
Content-Type: application/json
{"error": "no such user"}
reproduction
01
POST a registered email to /api/auth/password-reset → observe 200 `{sent: true}`.
02
POST an unregistered email → observe 404 `{error: 'no such user'}`.
03
Iterate a list of candidate emails to enumerate registered users.
recommended fix
Return an identical response whether or not the email exists (200 with a constant-time delay); send the mail only when the account is real. Rate limit per IP and per email.
python
user = db.users.find(email)
if user:
send_reset_email(user)
return Response({'sent': True}, status=200) # same body + timing either way
F-007
low
Server header discloses nginx version with published CVEs
severity
low
cvss
3.7
affected
All HTTPS responses
cwe
CWE-200
summary
All responses from acme.dev include a `Server: nginx/1.18.0` header. nginx 1.18.0 was released in April 2020 and has known CVEs (CVE-2021-23017, CVE-2022-41741, CVE-2022-41742) that have been patched in subsequent releases. The disclosed version narrows the attacker's exploit search and indicates the host has not been updated in over four years.
evidence
request
GET / HTTP/1.1
Host: acme.dev
response
HTTP/1.1 200 OK
Server: nginx/1.18.0
Date: 2026-05-20T03:47:14Z
Content-Type: text/html
...
reproduction
01
Issue `curl -I https://acme.dev/` and observe the `Server` header.
recommended fix
Suppress the version banner and upgrade off the EOL release (1.18).
nginx
server_tokens off;
F-008
informational
Strict-Transport-Security header not set on www.acme.dev
severity
informational
affected
https://www.acme.dev/
cwe
CWE-523
summary
Responses from `www.acme.dev` do not include a `Strict-Transport-Security` header. A network attacker positioned between the user and the server can downgrade subsequent requests to HTTP and intercept session cookies. HSTS preloading is also not configured.
evidence
request
GET / HTTP/1.1
Host: www.acme.dev
response
HTTP/1.1 200 OK
Content-Type: text/html
(no Strict-Transport-Security header)
reproduction
01
Issue `curl -I https://www.acme.dev/` and observe the absence of an HSTS header.
recommended fix
`Strict-Transport-Security: max-age=63072000; includeSubDomains; preload` on every HTTPS response. Submit to hstspreload.org once every subdomain serves HTTPS.
Pages do not set `X-Frame-Options` or a CSP `frame-ancestors` directive. Authenticated pages (including the billing settings page) can be embedded in a third-party site's iframe, enabling clickjacking attacks that trick users into performing actions on acme.dev while interacting with the attacker's overlay.
evidence
request
GET /settings/billing HTTP/1.1
Host: acme.dev
Cookie: session=...
response
HTTP/1.1 200 OK
Content-Type: text/html
(no X-Frame-Options or frame-ancestors)
reproduction
01
Issue an authenticated request to /settings/billing.
02
Observe the response has neither X-Frame-Options nor a frame-ancestors CSP directive.
03
Host a third-party page that embeds `<iframe src="https://acme.dev/settings/billing">` and observe the iframe load.
recommended fix
`Content-Security-Policy: frame-ancestors 'none'` (or `'self'` if you legitimately frame your own pages). `X-Frame-Options: DENY` as a legacy fallback for older browsers, not the primary control.
F-010
informational
Permissions-Policy header not set
severity
informational
affected
All HTML responses
cwe
CWE-1021
summary
Responses do not include a `Permissions-Policy` header. Embedded third-party iframes and scripts can request access to camera, microphone, geolocation, and other powerful browser APIs. Even if your own application does not use them, dependencies and ad/analytics scripts may. A restrictive default-deny policy is a low-cost defense.
evidence
request
GET / HTTP/1.1
Host: acme.dev
response
HTTP/1.1 200 OK
Content-Type: text/html
(no Permissions-Policy header)
reproduction
01
Issue `curl -I https://acme.dev/` and observe the absence of a Permissions-Policy header.
recommended fix
`Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()` on every response. Allow specific origins per directive when you genuinely use the feature, e.g., `payment=(self "https://js.stripe.com")`.
05what was tried
hosts found 9hosts tested 7third-party hosts skipped 2endpoints tested 186
discovery
subdomain discovery
tested
ports and services
tested
files and directories
tested
javascript and source maps
tested
archived urls
tested
secrets in public files
tested
configuration
tls setup
tested
security headers
tested
cross-origin rules
tested
cookie settings
tested
caching and proxy behavior
tested
authentication
login
tested
signup
tested
password reset
tested
session handling
tested
sso and oauth
tested
tokens and jwt
tested
access control
pages and apis without login
tested
access to other users' objects
tested
access between two test users
out of scope
privilege escalation
tested
admin and debug surfaces
tested
injection
sql injection
tested
nosql injection
not present
cross-site scripting
tested
template injection
not present
command injection
tested
xml external entities
not present
header injection
tested
server side
server-side request forgery
tested
file upload
tested
path traversal
tested
deserialization
not present
open redirect
tested
apis
api discovery
tested
graphql
not present
mass assignment
tested
rate limiting
tested
business logic
payments and pricing
tested
multi-step workflows
tested
race conditions
not reached
data and cloud
storage buckets
tested
database access rules
tested
subdomain takeover
tested
ai features
ai chat and llm features
not present
06target profile
a self-serve analytics dashboard for indie saas. team accounts, api keys, and stripe billing.
Next.js · Stripe · Cloudflare · Postgres
07clean up
clean up after the scan
delete the DNS verification record_recon-verify.acme.dev verification is done; the TXT record is no longer needed.
rotate the credentials you provided treat them as compromised: they passed through third-party infrastructure during the scan.
artifacts created during testing
test account we registeredpentest-704218@example.com delete this user from your admin panel; we created it to test authenticated flows.
a test comment we postedcomment on /dashboards/demo (marker PENTEST_DO_NOT_USE) delete this comment; we posted it during the scan and could not remove it automatically.
08limitations
this test was run from outside, the way an attacker on the internet sees the application. no source code, internal network or infrastructure access was used, so issues visible only from inside are out of reach.
the results describe the target as it was during the test window. later changes to the application or its infrastructure are not covered.
testing was time-boxed. areas marked not reached under what was tried were not tested before time ran out.
this report records a security test. it is not a certification or a compliance audit.