Phase 2: harden Compass fetcher for the Cloudflare login-POST challenge

- retry the login on a fresh browser context (stale cf_chl_rc_ni cookies loop
  the next challenge) and fail loudly with cloudflare_login_post_challenge
- detect the Compass login-rejection message and second-factor prompts
- docs: record that Cloudflare's managed challenge blocks the headless login
  POST (GET passes), what was tried, and the unblock options

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip Builder
2026-10-15 16:14:50 +11:00
parent 6ff29df57c
commit 78d2822b39
2 changed files with 99 additions and 33 deletions

View File

@@ -40,6 +40,7 @@ const DEFAULT_URL = "https://nhs-vic.compass.education/login.aspx";
const DEFAULT_UA =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
const DAEMON_SOCKET = process.env.COMPASS_DAEMON_SOCKET || "/tmp/pi-browser-daemon.sock";
const LOGIN_ATTEMPTS = Number(process.env.COMPASS_LOGIN_ATTEMPTS || 4);
// Cloudflare's managed challenge loops forever under the default HeadlessChrome
// UA. A normal desktop Chrome UA clears it. See AUD-104.
const CHALLENGE_TITLE = /just a moment|verifying|checking your browser|attention required/i;
@@ -217,9 +218,9 @@ async function clearChallenge(api, sessionId, url) {
return state;
}
async function login(api, sessionId, username, password) {
async function attemptLogin(api, sessionId, username, password) {
const form = await evalJs(api, sessionId, "!!document.querySelector('#username')");
if (!form) throw new Error("login form (#username) not present");
if (!form) return { formMissing: true, challenged: false, errorText: null, state: await pageState(api, sessionId) };
await evalJs(
api,
sessionId,
@@ -237,12 +238,17 @@ async function login(api, sessionId, username, password) {
})()`,
);
await evalJs(api, sessionId, "document.querySelector('#button1').click(); true");
return waitFor(
api,
sessionId,
(s) => !/login\.aspx/i.test(s.url) || /dashboard|home|planner|messages/i.test(s.url),
{ attempts: 60, everyMs: 1000 },
);
// Cloudflare challenges the login POST intermittently; poll until it clears.
const state = await waitFor(api, sessionId, (s) => !CHALLENGE_TITLE.test(s.title), { attempts: 60, everyMs: 1000 });
const challenged = CHALLENGE_TITLE.test(state.title);
const errorText = challenged
? null
: await evalJs(
api,
sessionId,
"(() => { const m = (document.body ? document.body.innerText : '').match(/Sorry,[^\\n]*/i); return m ? m[0].trim() : null; })()",
);
return { challenged, errorText, state, formMissing: false };
}
async function detectSecondFactor(api, sessionId) {
@@ -250,11 +256,15 @@ async function detectSecondFactor(api, sessionId) {
api,
sessionId,
`(() => {
const url = location.href.toLowerCase();
const txt = (document.body ? document.body.innerText : '').toLowerCase();
const loginish = /login|otp|verif|one-?time|two-?factor/.test(url) ||
/verification code|one-?time code|enter the code|we sent|two-factor|2fa/.test(txt);
const otpInput = [...document.querySelectorAll('input')].some(
(i) => /otp|one-?time|passcode|verification|code/i.test((i.name||'') + ' ' + (i.id||'')),
);
return otpInput || /verification code|one-?time code|enter the code|we sent|two-factor|2fa/.test(txt);
const hasPassword = !!document.querySelector('#password');
return otpInput && loginish && !hasPassword;
})()`,
);
}
@@ -297,7 +307,15 @@ async function main() {
fs.mkdirSync(outDir, { recursive: true });
const api = await connectDaemon(`compass-fetch-${process.pid}`);
const page = await newPage(api, args.child);
let page = await newPage(api, args.child);
// A fresh browser context per login attempt: stale Cloudflare challenge
// cookies (cf_chl_rc_ni) from a blocked POST loop the next challenge.
const freshPage = async () => {
const stale = page.browserContextId;
page = await newPage(api, args.child);
try { await api.send("Target.disposeBrowserContext", { browserContextId: stale }); } catch {}
return page;
};
const manifest = {
child: args.child,
runId,
@@ -311,18 +329,32 @@ async function main() {
};
try {
const loginState = await clearChallenge(api, page.sessionId, baseUrl);
if (!loginState) {
manifest.errors.push("cloudflare_challenge_not_cleared");
fs.writeFileSync(path.join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
return 2;
}
if (args.login) {
const username = process.env.COMPASS_USERNAME;
const password = process.env.COMPASS_PASSWORD;
if (!username || !password) throw new Error("--login requires COMPASS_USERNAME and COMPASS_PASSWORD");
const afterLogin = await login(api, page.sessionId, username, password);
// Cloudflare intermittently 403s the login POST even when the GET is fine.
// Retry on a clean context until the POST reaches Compass.
let attempt = null;
for (let n = 1; n <= LOGIN_ATTEMPTS; n++) {
if (!(await clearChallenge(api, page.sessionId, baseUrl))) {
manifest.errors.push("cloudflare_challenge_not_cleared");
fs.writeFileSync(path.join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
return 2;
}
attempt = await attemptLogin(api, page.sessionId, username, password);
if (!attempt.challenged) break;
console.error(`login POST blocked by Cloudflare (attempt ${n}/${LOGIN_ATTEMPTS}); retrying on a fresh context`);
if (n < LOGIN_ATTEMPTS) {
await freshPage();
await sleep(8000);
}
}
if (attempt.challenged) {
manifest.errors.push("cloudflare_login_post_challenge");
fs.writeFileSync(path.join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
return 2;
}
if (await detectSecondFactor(api, page.sessionId)) {
manifest.secondFactor = true;
manifest.pages.push(await storePage(api, page.sessionId, outDir, "second-factor-required"));
@@ -330,13 +362,22 @@ async function main() {
console.error("Second factor / one-time code required. STOP — do not guess.");
return 3;
}
manifest.loggedIn = true;
manifest.pages.push(await storePage(api, page.sessionId, outDir, "post-login"));
if (!/dashboard|home|planner|messages/i.test(afterLogin.url) && /login\.aspx/i.test(afterLogin.url)) {
const stillLoginForm = await evalJs(api, page.sessionId, "!!document.querySelector('#username')");
if (attempt.errorText || stillLoginForm) {
manifest.errors.push(`login_rejected: ${attempt.errorText || "login form still present"}`);
manifest.pages.push(await storePage(api, page.sessionId, outDir, "login-rejected"));
fs.writeFileSync(path.join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
return 4;
}
manifest.loggedIn = true;
manifest.pages.push(await storePage(api, page.sessionId, outDir, "post-login"));
} else {
const loginState = await clearChallenge(api, page.sessionId, baseUrl);
if (!loginState) {
manifest.errors.push("cloudflare_challenge_not_cleared");
fs.writeFileSync(path.join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
return 2;
}
manifest.pages.push(await storePage(api, page.sessionId, outDir, "login"));
}