TL;DR: The static front end was clean; the serverless function layer behind it was not. I tested origin handling, rate limiting and the mail chain, and I explain why platform defaults drift.
The scenario
I ran this as a black-box review: no credentials, only the publicly visible surface. Here is what I saw before I touched anything.
A marketing and lead generation site built on a hosted site builder, served through a content delivery network with serverless functions for form handling. Scope covered the edge configuration, function exposure, form handling, mail authentication and administrative surface.
I removed all identifying information. The target is described only by sector and technology class so the pattern can be reused.
The pattern
The pattern I recognised — and that I keep finding in similar estates:
The site builder market produces a specific architecture: pre-rendered pages from a visual editor, a content delivery network in front, and a small number of serverless functions for the parts that cannot be static. Contact forms, coupon validation and authentication all live in that last group. It is a reasonable design and it moves almost all the attack surface out of the server you can inspect, which is precisely the problem.
Static pages are genuinely hard to attack. There is no interpreter on the request path, so the injection families have nothing to bind to. The functions are where the estate becomes interesting again, and they are usually the least documented part of the deployment, because they were added to make one form work and were never treated as an application. Each function is a small program with its own environment, its own secrets and its own input, deployed from a pipeline nobody reviews.
The other recurring theme is that platform-managed sites inherit defaults nobody chose. Mail authentication, security headers and subdomain policy are configured once during onboarding and then drift. What looks like a hardened static site is often a hardened front end sitting on an unexamined function layer.
Findings
| Severity | Finding | Evidence |
|---|---|---|
| HOCH | Function endpoint callable without origin validation | Accepts cross-origin form posts by design |
| MITTEL | No rate limit on the form function | Unlimited submissions to a mail-sending endpoint |
| MITTEL | Mail authentication absent for the sending domain | Spoofable outbound mail |
| MEDIUM | Platform preview environment indexed | Draft content publicly reachable |
| LOW | Framework banner on function responses | Runtime and platform version identifiable |
| LOW | Subdomain policy permissive | Wildcard records for asset hosts |
The attack path
Reproduction in my lab
Every command below targets a lab container I control. Nothing here is aimed at a live system.
Test your own function endpoint for origin and rate handling
# target: your own deployed function
$ curl -s -o /dev/null -w '%{http_code}\n' 'https://example.org/api/contact'
# does it care where the request came from
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST \
-H 'Origin: https://evil.example' -H 'Content-Type: application/json' \
-d '{"email":"a@b.example"}' 'https://example.org/api/contact'
# and is it rate limited
$ for i in $(seq 1 12); do
curl -so /dev/null -w '%{http_code} ' -X POST -H 'Content-Type: application/json' \
-d "{\"n\":$i}" 'https://example.org/api/contact'
done; echo
Detection in your own estate
Inventory functions behind a static front end
$ curl -s https://example.org/ | grep -oiE '(href|src|action)="[^"]+"' \
| grep -iE 'api|form|submit|action|endpoint' | sort -u
# the builder's own form endpoint is often the same one
$ curl -s https://example.org/ | grep -oiE 'data-[a-z-]*(endpoint|form|action)="[^"]+"' | sort -u
Verify the mail authentication chain for the sending domain
$ dig +short TXT example.org | grep -i spf
$ dig +short TXT _dmarc.example.org
$ dig +short TXT selector1._domainkey.example.org | head
Remediation
Validate origin and rate-limit at the edge, before the function runs
# rate limit the form path, keyed on client
limit_req_zone $binary_remote_addr zone=forms:10m rate=5r/m;
location = /api/contact {
limit_req zone=forms burst=3 nodelay;
limit_req_status 429;
if ($http_origin !~* '^https://(www\.)?example\.org$') {
return 403;
}
proxy_pass http://functions;
}
Set an explicit, enforced mail authentication policy
# SPF: authorise exactly the sending services, then hard fail
v=spf1 include:<sending-provider> -all
# DMARC: start at monitoring, move to quarantine, then reject
v=DMARC1; p=none; rua=mailto:dmarc-reports@example.org; fo=1
# after two clean reporting cycles:
# v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.org
# after four:
# v=DMARC1; p=reject; rua=mailto:dmarc-reports@example.org
Once an attacker gains access, they can perform the following actions:
Takeaways
- A static front end does not mean a static estate.
- Functions added to make a form work deserve a real code review.
- Rate limits belong at the edge, where the CDN already sees every request.
- Platform defaults drift; mail policy and subdomain policy need an owner.