CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
178 lines
7.4 KiB
JavaScript
178 lines
7.4 KiB
JavaScript
/**
|
|
* 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:
|
|
*
|
|
* <input type="text" id="login_username" name="login_username"
|
|
* class="form_input" placeholder="Username" autocomplete="off">
|
|
*
|
|
* 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 <form> ──────────────────────
|
|
// The ASUS page submits via <div class="button" onclick="preLogin();">, 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');
|