Use your browser's Print → Save as PDF.
F5/100
SurfaceCheckr security report
shop.acme-demo.example
Scanned 2026-06-01

shop.acme-demo.example is serving a live secrets file and an open admin panel to the entire internet - no login, no exploit, just a GET request. Both are fixable today; both are exactly what an attacker scans for first. This is an example report; scan your own domain to get yours.

What an attacker would notice

critical Exposed: /.env file readable over HTTP
Your deployment serves /.env directly to anyone who asks. The file contains a live database connection string and a payment-processor secret key. There is no login and no exploit involved - a single unauthenticated GET request returns the whole file. Automated scanners hit this path on every site they crawl, so a leaked /.env is typically found within hours of going public.
What this means: Anyone on the internet can read the password to your production database and your live payment key. With the database URL they can connect directly and read or alter customer records, orders, and password hashes. With the payment key they can move money and issue refunds as you. Treat every credential in this file as already compromised.
Found on: https://shop.acme-demo.example/.env
Evidence (redacted):
DATABASE_URL=postgres://app:****@db.shop-acme.internal:5432/shop_prod
STRIPE_SECRET_KEY=sk_live_****REDACTED****
SESSION_SECRET=****REDACTED****
SMTP_PASSWORD=****REDACTED****
Fix: Block dotfiles at the web server so /.env can never be served, then rotate every credential the file contained - assume it has already been copied.
Where: Web server / edge config (Nginx, Apache, or your host's headers/redirects file).
# Nginx: refuse any path that starts with a dot
location ~ /\. {
  deny all;
  return 404;
}
Rotate the credentials FIRST, then block the file. Blocking access does not un-leak a secret that was already public - a copied database password is still valid until you change it.
high Exposed admin panel at /admin (no auth wall)
The admin login is reachable from the open internet and fingerprints as a known CMS admin console. Credential-stuffing and brute-force tools target this exact path with lists of leaked passwords. An admin panel does not belong on the public web - it should sit behind IP allow-listing, a VPN, or an auth proxy, so that reaching the login form already requires being inside your perimeter.
What this means: An attacker who guesses or reuses one admin password owns your whole site - content, customer data, and the ability to inject a card skimmer into your checkout. Because the panel is public, they can attack it continuously and silently, with no need to be anywhere near your network.
Found on: https://shop.acme-demo.example/admin
Fix: Restrict /admin to known office and VPN IP ranges, or put it behind an authenticating proxy (Cloudflare Access, an SSO gateway) so unauthenticated visitors never reach the login form.
Where: Edge / reverse proxy in front of the app, or the app's host firewall rules.
# Nginx: only let known networks reach the admin panel
location /admin {
  allow 203.0.113.0/24;   # office
  allow 198.51.100.7;     # VPN egress
  deny all;
}
Confirm the allow-list covers every network your real admins use (remote staff, VPN egress IPs) before you deploy, or you will lock yourself out alongside the attackers.
high Subdomain takeover risk: dangling CNAME on assets.shop.acme-demo.example
This subdomain points (via CNAME) at a cloud bucket host that no longer claims it. The DNS record is live but the target is unowned, so anyone can register that target on the provider and start serving their own content from your subdomain. The browser will show your domain, your padlock, and your name.
What this means: An attacker can stand up a convincing phishing page on a subdomain your customers already trust - assets.shop.acme-demo.example reads as you, not as a stranger. It is also a clean way to steal cookies scoped to your parent domain and to bypass email and link filters that trust your brand.
Found on 2 pages:
https://assets.shop.acme-demo.example/
https://assets.shop.acme-demo.example/img/
Fix: Remove the dangling DNS record, or re-claim the resource on the provider it points at. Then audit every CNAME that targets a third-party platform (S3, GitHub Pages, Heroku, Netlify) for unclaimed targets.
Where: Your DNS provider's zone for shop.acme-demo.example.
# Delete the record whose target is no longer claimed:
assets  CNAME  acme-demo-assets.s3-website.amazonaws.com.   ; <- remove
Confirm the target is genuinely unused before deleting - if a live service still depends on it, pull the dependency first, then remove the record.

How an attacker could chain these

An attacker's five-minute path in
It starts with the /.env file, which hands over a live database password and a payment key for free. The open /admin panel is the next stop - somewhere to try those same credentials, or to drop a card skimmer into the checkout your customers actually use. And the dangling subdomain is a trusted-looking home for the phishing page that harvests the next round of logins. None of these needs an exploit. They're things left sitting in public view, and they chain.

Hygiene & hardening (4)

medium Missing HSTS header
The site doesn't send Strict-Transport-Security, so a visitor's first request can be silently downgraded to plain HTTP by a network attacker before the redirect to HTTPS happens - the window where session cookies and login forms are exposed.
What this means: On hostile networks (cafe wifi, a compromised router) an attacker can intercept that first unencrypted request and read or tamper with it. HSTS tells browsers to always use HTTPS from the very first byte, closing the window.
Fix: Send Strict-Transport-Security on every HTTPS response, then submit the domain to the HSTS preload list so browsers enforce it even on a first visit.
Where: Web server response headers (or your edge / CDN header rules).
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
includeSubDomains forces HTTPS on every subdomain - make sure they all serve valid TLS before you add it, or they'll break.
medium Content-Security-Policy allows 'unsafe-inline'
The CSP permits inline scripts, which defeats much of its value as an XSS mitigation - a single injected <script> runs unimpeded, exactly the case CSP exists to stop.
What this means: If any input on the site is ever reflected without escaping, an attacker's script runs in your visitors' browsers - stealing sessions or injecting a card skimmer at checkout. A strict CSP is the safety net that stops that script from executing at all.
Fix: Move inline scripts to external files or attach a per-request nonce, then drop 'unsafe-inline' from script-src.
Where: Content-Security-Policy response header.
Content-Security-Policy: script-src 'self' 'nonce-<per-request>'; object-src 'none'; base-uri 'self'
Roll this out with Content-Security-Policy-Report-Only first and watch the violation reports, so you catch any legitimate inline script before it's blocked for real users.
low Cookies missing Secure / HttpOnly flags
A session cookie is set without HttpOnly (so client-side JavaScript can read it) and without Secure (so it can be sent over plain HTTP).
What this means: Without HttpOnly, any XSS on the site can steal the session cookie outright. Without Secure, the cookie can leak over an unencrypted connection. Both flags are free to add and close two easy session-hijack routes.
Fix: Set both flags, plus SameSite, on every session cookie.
Where: Wherever the app issues the session cookie (framework session config or Set-Cookie call).
Set-Cookie: session=...; Secure; HttpOnly; SameSite=Lax; Path=/
info Server technology disclosed in headers
Response headers reveal the exact server and framework versions, giving an attacker a head start on matching known CVEs to your stack.
What this means: This isn't a hole by itself, but it removes the guesswork - an attacker can go straight to exploits for your exact version instead of probing blindly. Stripping it is cheap and buys you nothing but quiet.
Fix: Strip or generalise the Server and X-Powered-By headers at the edge.
Where: Web server / reverse proxy header config.
# Nginx
server_tokens off;
proxy_hide_header X-Powered-By;