/** * tests/js/test_field_heuristics.js — run with: * node tests/js/test_field_heuristics.js * * Pins the field-detection predicates in extension/content/content.js against * the ASUS RT-AX88U router login page, which the extension could not detect. * * The username input there is about as obvious as they come: * * * * but "off" was in the NON_CRED_AC deny-list, so _isLikelyUsernameField() * returned false at that check and never reached the name/id/placeholder * keyword test. `autocomplete="off"` says nothing about whether a field holds a * credential — routers and admin panels set it specifically to discourage * password managers. * * SCOPE: this exercises the regex predicates extracted from the real source * file, not the DOM traversal in _hasPasswordSibling() (which needs a browser). * Those paths are covered by the manual check in the accompanying notes. */ 'use strict'; const fs = require('fs'); const path = require('path'); const SRC = fs.readFileSync( path.join(__dirname, '..', '..', 'extension', 'content', 'content.js'), 'utf8', ); let failures = 0; function check(label, got, want) { if (got !== want) { failures++; console.log(`FAIL ${label}\n got=${got} want=${want}`); } } /** Pull a named regex literal out of the real source rather than copying it. */ function extractRegex(name) { const m = SRC.match(new RegExp(`const ${name} =\\s*(/[^\\n]+/[a-z]*);`)); if (!m) throw new Error(`could not find regex ${name} in content.js`); // eslint-disable-next-line no-eval return eval(m[1]); } const CRED_HINTS = extractRegex('CRED_HINTS'); const NON_CRED_AC = extractRegex('NON_CRED_AC'); // ── The bug: autocomplete="off" must not be a negative signal ─────────────── check('NON_CRED_AC rejects "off"', NON_CRED_AC.test('off'), false); check('NON_CRED_AC rejects "OFF" (case-insensitive)', NON_CRED_AC.test('OFF'), false); // Anti-autofill junk values sites use must also fall through to the keywords. for (const junk of ['nope', 'no', 'false', 'disabled', 'new-user', 'chrome-off']) { check(`NON_CRED_AC lets junk value "${junk}" fall through`, NON_CRED_AC.test(junk), false); } // ── But genuine non-credential tokens must still be rejected ─────────────── for (const token of ['name', 'given-name', 'family-name', 'organization', 'street-address', 'country', 'postal-code', 'search', 'url', 'bday', 'sex', 'photo', 'language', 'new-password', 'current-password', 'one-time-code']) { check(`NON_CRED_AC still rejects "${token}"`, NON_CRED_AC.test(token), true); } // ── The ASUS field's identifying attributes must read as a credential ─────── const ASUS_USERNAME_ATTRS = ['login_username', 'login_username', 'Username', ''].join(' '); check('ASUS username attrs match CRED_HINTS', CRED_HINTS.test(ASUS_USERNAME_ATTRS), true); // Other real-world username fields. for (const attrs of ['user_name', 'j_username', 'email', 'userEmail', 'account', 'Login ID', 'mobile', 'tel']) { check(`CRED_HINTS matches "${attrs}"`, CRED_HINTS.test(attrs), true); } // Fields that must NOT be treated as credential inputs on keywords alone. for (const attrs of ['q', 'search-query', 'first_name', 'zipcode', 'street', 'company', 'comment']) { check(`CRED_HINTS does not match "${attrs}"`, CRED_HINTS.test(attrs), false); } // ── Submit-control detection for pages with no
────────────────────── // The ASUS page submits via
, so no // "submit" event ever fires and the save prompt never appeared. const clsMatch = SRC.match( /return (\/\(\^\|\[\\s_-\]\)\(btn\|button[^\n]+\/)\.test\(cls\);/, ); if (!clsMatch) { failures++; console.log('FAIL could not find the submit-control class regex in content.js'); } else { // eslint-disable-next-line no-eval const BTN_CLS = eval(clsMatch[1]); check('recognises class="button" (ASUS)', BTN_CLS.test('button'), true); for (const cls of ['btn', 'btn btn-primary', 'submit', 'login-button', 'signin', 'sign-in', 'form_btn button']) { check(`recognises class="${cls}"`, BTN_CLS.test(cls), true); } for (const cls of ['form_input', 'container', 'buttonish', 'rebutton']) { check(`ignores class="${cls}"`, BTN_CLS.test(cls), false); } } // The click and keydown fallbacks must actually be registered. check('click fallback registered', /addEventListener\(\s*"click"/.test(SRC), true); check('keydown fallback registered', /addEventListener\(\s*"keydown"/.test(SRC), true); check('submit listener retained', /addEventListener\(\s*"submit"/.test(SRC), true); // ── _isTrustworthyOrigin: where credentials may be filled ────────────────── // Extracted from the real source and evaluated against a stubbed `location`, // so this exercises the shipped function rather than a copy of its rules. { const fnSrc = SRC.match( /function _isTrustworthyOrigin\(\) \{[\s\S]*?\n \}/, ); if (!fnSrc) { failures++; console.log('FAIL could not extract _isTrustworthyOrigin from content.js'); } else { const make = new Function( 'location', `${fnSrc[0]}; return _isTrustworthyOrigin();`, ); const at = (protocol, hostname) => make({ protocol, hostname }); // HTTPS is always fine. check('https is trustworthy', at('https:', 'example.com'), true); // Local devices over plain HTTP — routers, NAS, printers. Dropping // http://*/* entirely would break exactly these. for (const host of ['localhost', '127.0.0.1', '::1', 'router.local', '10.0.0.1', '192.168.1.1', '172.16.5.4', '172.31.0.1', '169.254.1.1', 'nas.lan', 'box.home']) { check(`http://${host} is treated as local`, at('http:', host), true); } // Plaintext on the public internet must warn. for (const host of ['example.com', 'bank.co.uk', '8.8.8.8', '172.15.0.1', '172.32.0.1', '11.0.0.1', '192.169.1.1', 'evil-localhost.com', 'localhost.evil.com', '127.0.0.1.evil.com']) { check(`http://${host} is NOT trusted`, at('http:', host), false); } } } // ── Autologin must not click a control that discards the login ──────────── { const m = SRC.match(/var _NEGATIVE_CONTROL = (\/[^\n]+\/[a-z]*);/); if (!m) { failures++; console.log('FAIL could not find _NEGATIVE_CONTROL in content.js'); } else { // eslint-disable-next-line no-eval const NEG = eval(m[1]); for (const label of ['Cancel', 'Reset', 'Go back', 'Forgot password?', 'Register', 'Sign up', 'Create account']) { check(`autologin skips "${label}"`, NEG.test(label), true); } for (const label of ['Sign In', 'Log in', 'Submit', 'Continue', 'OK']) { check(`autologin allows "${label}"`, NEG.test(label), false); } } } // ── Summary (must stay last so every block above is counted) ─────────────── if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); } console.log('OK: field heuristics assertions passed');