qa-form-security
Consolidated form security skill. Owns CSRF tokens, autocomplete attributes on sensitive fields, and captcha presence/visibility. Replaces 3 overlapping security skills.
SKILL.mdskills/qa-form-security/SKILL.md
GitHubqa-form-security — Consolidated Form Security Skill
Single skill owning ALL form-security findings. Pure passive detection — no interaction. Single browser_evaluate round-trip per cell.
What it checks (10 issue types — gap-fix 2026-06-03)
CSRF
| issueType | severity | catches |
|---|---|---|
csrfTokenMissing |
high | POST form with no CSRF token (no hidden input, no data attr, no meta tag) |
csrfTokenNotRotated |
medium | After successful submit, the CSRF token is unchanged — stale-token replay is possible (Phase 2: interactive) |
Autocomplete
| issueType | severity | catches |
|---|---|---|
autocompleteMissingEmail |
medium | Email field missing autocomplete="email" or "username" |
autocompleteWrongPassword |
medium | Password field wrong autocomplete (should be current-password or new-password) |
autocompleteMissingName |
low | Name field missing autocomplete="given-name" / "family-name" |
autocompleteMissingTel |
low | Phone field missing autocomplete="tel" |
autocompleteOffAntiPattern |
low | autocomplete="off" on credential field — browsers ignore it |
Captcha
| issueType | severity | catches |
|---|---|---|
captchaMissingOnSignup |
medium | Signup/register form has no captcha widget |
captchaMissingOnContact |
low | Contact form has no captcha — spam target |
captchaContainerHidden |
high | Captcha widget present but its container is hidden — users can't solve it |
Self-skip
Return [] ONLY if the page has no testable inputs (input/textarea, excluding hidden/submit/button) AND no captcha widgets. Do NOT skip on a missing <form> — the autocomplete checks scan all inputs and the captcha check is page-level, so they run on form-less login/signup groups too. (CSRF detection still applies only to real <form method=post> — a form-less group submits via fetch, where the CSRF token would be an HTTP header that isn't visible in the DOM, so CSRF can't be judged there.)
Phase 2 — Interactive CSRF rotation test (NEW, added 2026-06-03)
Phase 2 runs AFTER the passive probe completes IF:
- A CSRF token was detected (hidden input named
csrf_token,_csrf,authenticity_token, OR meta tag). - The page has a submittable form with valid-looking fields.
customize.toml → [form] enable_csrf_rotation_test = true(default: true).
Algorithm:
Capture the current CSRF token via
browser_evaluate:() => { const hidden = document.querySelector('input[type="hidden"][name*="csrf" i], input[type="hidden"][name*="_token" i], input[type="hidden"][name="authenticity_token"]'); const meta = document.querySelector('meta[name*="csrf" i]'); return { hidden: hidden ? hidden.value : null, meta: meta ? meta.getAttribute('content') : null }; }Save as
token_before = { hidden, meta }.Fill the form with safe valid values and submit (reuse the submit-state Phase from qa-form-validation if available).
browser_wait_for(time = 2000)to let the server response settle.Re-capture the token via the same probe →
token_after.Compare:
- If
token_before.hidden === token_after.hiddenAND both are non-empty → emit:{ "issueType": "csrfTokenNotRotated", "severity": "medium", "selector": "input[type='hidden'][name*='csrf']", "description": "CSRF token (hidden input) is unchanged after submit. Stale token can be replayed in a CSRF attack. Rotate the token on every state-changing request." } - Same check for meta tag.
- If both tokens changed → no finding (good).
- If token went to null after submit (e.g. page redirected, form disappeared) → no finding (inconclusive).
- If
Skip Phase 2 if:
- Passive probe found
csrfTokenMissing(no point testing rotation of a missing token). - Form submission would trigger destructive action (heuristic: form action contains
/delete,/destroy,/cancel-account, or method is DELETE). In that case skip with a logged note.
Probe (browser_evaluate)
() => {
const sel = el => (el.tagName.toLowerCase() + (el.id ? `#${el.id}` : '')).slice(0, 120);
const bb = el => { const r = el.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) }; };
const formSel = f => f.id ? `#${f.id}` : (f.action ? `form[action="${f.action.slice(0,80)}"]` : `form:nth-of-type(${[...document.querySelectorAll('form')].indexOf(f)+1})`);
const out = [];
// ============ 1. CSRF ============
const metaCsrf = document.querySelector('meta[name*="csrf" i], meta[name="_token"], meta[name*="xsrf" i]');
const hasGlobalCsrf = !!metaCsrf && metaCsrf.getAttribute('content');
for (const f of document.querySelectorAll('form')) {
if (out.length >= 8) break;
const method = (f.method || 'get').toLowerCase();
if (method === 'get' || method === '') continue;
const r = f.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
const csrfField = f.querySelector(
'input[type="hidden"][name*="csrf" i], input[type="hidden"][name="_token"], ' +
'input[type="hidden"][name="authenticity_token"], input[type="hidden"][name*="xsrf" i]'
);
const hasFieldCsrf = !!csrfField && csrfField.value && csrfField.value.length >= 8;
const dataCsrf = f.dataset && (f.dataset.csrf || f.dataset.csrfToken || f.dataset.token);
const hasDataCsrf = !!dataCsrf && dataCsrf.length >= 8;
if (!hasFieldCsrf && !hasDataCsrf && !hasGlobalCsrf) {
out.push({ issueType: 'csrfTokenMissing', severity: 'high', selector: formSel(f),
description: `POST form ${formSel(f)} action=${f.action || '(empty)'} has no CSRF token — vulnerable to cross-site request forgery`,
bbox: bb(f) });
}
}
// ============ 2. AUTOCOMPLETE ============
const passwordFields = [...document.querySelectorAll('input[type="password"]')];
const formContext = (document.body && document.body.innerText || '').toLowerCase();
const isSignup = passwordFields.length >= 2 ||
(/sign\s*up|create\s*account|register/.test(formContext) && !/sign\s*in|log\s*in/.test(formContext));
const inpSel = el => (el.id ? `#${el.id}` : `input[name="${el.name}"]`).slice(0,120);
for (const input of document.querySelectorAll('input, textarea')) {
if (out.length >= 20) break;
const r = input.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
if (input.type === 'hidden' || input.type === 'submit' || input.type === 'button') continue;
const auto = (input.getAttribute('autocomplete') || '').trim().toLowerCase();
const type = input.type;
const name = (input.name || input.id || '').toLowerCase();
if (type === 'email' || /email/.test(name)) {
if (!auto || (auto !== 'email' && auto !== 'username')) {
out.push({ issueType: 'autocompleteMissingEmail', severity: 'medium', selector: inpSel(input),
description: `Email input has autocomplete="${auto || 'none'}" — should be "email" or "username"`, bbox: bb(input) });
continue;
}
}
if (type === 'password') {
const expected = isSignup ? 'new-password' : 'current-password';
if (auto !== expected) {
out.push({ issueType: 'autocompleteWrongPassword', severity: 'medium', selector: inpSel(input),
description: `Password input has autocomplete="${auto || 'none'}" — should be "${expected}" for ${isSignup ? 'signup' : 'login'} flow`, bbox: bb(input) });
}
continue;
}
if (/first.?name|given.?name|fname/.test(name)) {
if (auto !== 'given-name' && auto !== 'name') {
out.push({ issueType: 'autocompleteMissingName', severity: 'low', selector: inpSel(input),
description: `First name field missing autocomplete="given-name"`, bbox: bb(input) });
}
continue;
}
if (/last.?name|family.?name|surname|lname/.test(name)) {
if (auto !== 'family-name') {
out.push({ issueType: 'autocompleteMissingName', severity: 'low', selector: inpSel(input),
description: `Last name field missing autocomplete="family-name"`, bbox: bb(input) });
}
continue;
}
if (type === 'tel' || /phone|mobile|tel/.test(name)) {
if (auto !== 'tel' && auto !== 'tel-national') {
out.push({ issueType: 'autocompleteMissingTel', severity: 'low', selector: inpSel(input),
description: `Phone input missing autocomplete="tel"`, bbox: bb(input) });
}
continue;
}
if (auto === 'off' && (type === 'email' || type === 'password' || type === 'text')) {
out.push({ issueType: 'autocompleteOffAntiPattern', severity: 'low', selector: inpSel(input),
description: `Input uses autocomplete="off" — most browsers ignore this; use specific tokens like new-password instead`,
bbox: bb(input) });
}
}
// ============ 3. CAPTCHA ============
const hasRecaptcha = !!document.querySelector('.g-recaptcha, [data-sitekey], iframe[src*="recaptcha"], iframe[title*="reCAPTCHA"]');
const hasHCaptcha = !!document.querySelector('.h-captcha, [data-hcaptcha-sitekey], iframe[src*="hcaptcha"]');
const hasTurnstile = !!document.querySelector('.cf-turnstile, iframe[src*="turnstile"]');
const hasCaptcha = hasRecaptcha || hasHCaptcha || hasTurnstile;
const bodyText = (document.body && document.body.innerText || '').toLowerCase();
const looksLikeAuth = /(sign\s*in|log\s*in|sign\s*up|register|create\s*account)/i.test(bodyText) && !!document.querySelector('input[type="password"]');
const looksLikeContact = /contact\s*us|get\s*in\s*touch|send\s*message/i.test(bodyText) && !!document.querySelector('textarea, input[name*="message" i]');
if (!hasCaptcha && looksLikeAuth && /(sign\s*up|register|create\s*account)/i.test(bodyText)) {
out.push({ issueType: 'captchaMissingOnSignup', severity: 'medium', selector: 'form',
description: 'Signup/register form has no captcha widget — potential bot-account creation vector',
bbox: { x:0, y:0, w:200, h:80 } });
}
if (!hasCaptcha && looksLikeContact) {
out.push({ issueType: 'captchaMissingOnContact', severity: 'low', selector: 'form',
description: 'Contact / message form has no captcha widget — likely spam target',
bbox: { x:0, y:0, w:200, h:80 } });
}
for (const c of document.querySelectorAll('.g-recaptcha, .h-captcha, .cf-turnstile')) {
if (out.length >= 28) break;
const r = c.getBoundingClientRect();
const style = getComputedStyle(c);
if (style.display === 'none' || style.visibility === 'hidden' || (r.width === 0 && r.height === 0)) {
out.push({ issueType: 'captchaContainerHidden', severity: 'high', selector: sel(c),
description: `Captcha widget ${sel(c)} present but container hidden (display=${style.display}, w×h=${r.width}×${r.height}) — users can't solve it`,
bbox: bb(c) });
}
}
return out;
}
Migration
[detectors]
qa-form-security = true # NEW
qa-detect-form-csrf = false
qa-detect-form-autocomplete = false
qa-detect-form-captcha = false