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

@@ -1,8 +1,8 @@
# Phase 2 — Compass login + fetch notes
Status: **partially proven.** The browser path to Compass works; the login and
per-child fetch are blocked on a Compass credential that is not available to the
agent. See AUD-104.
Status: **blocked on Cloudflare.** The credential is available and the login
page loads, but Cloudflare's managed challenge blocks the login **POST** under
the headless harness. See AUD-104.
## Harness used
@@ -41,6 +41,27 @@ The fix that works:
With both, the challenge clears in ~5 s and the real login page loads. Both are
implemented in `scripts/compass-fetch.mjs`.
### The login POST is the hard part
When the form is submitted (`POST /login?handler=Login`) Cloudflare returns a
**403 managed challenge** and serves `Just a moment...`. Under headless Chrome
that challenge does not clear (`iframes=0`, body "Compass is verifying your
connection..."), so Compass never sees the login. Captured response headers on
the blocked POST show `server: cloudflare`; the GET of the same page passes.
Tested and did **not** fix it: a normal desktop-Chrome UA override, isolated
browser contexts, `Sec-CH-UA` user-agent metadata, focus/visibility emulation,
client hints, and long waits (up to 4 minutes).
The POST passes only intermittently. It was observed returning `200` twice, but
five further attempts (including after a 75 s cooldown, each in a fresh context)
were all challenged. `scripts/compass-fetch.mjs` retries the login on a fresh
browser context up to `COMPASS_LOGIN_ATTEMPTS` (default 4) and fails loudly with
`cloudflare_login_post_challenge` (exit 2) if none get through.
This is the Phase 2 risk: the harness can read Compass but cannot log in from
headless Chrome.
## Proof stored (2026-10-15)
```
@@ -56,17 +77,21 @@ var/raw/harry/<runId>/manifest.json
## Blocked on
A Compass account credential (not `HeadlessChrome`-related, not a Cloudflare
issue). Nothing matching `COMPASS*`, `SCHOOL*`, `NORTHCOTE*`, `HARRY*`, `FINN*`,
or `TEAMS*` exists in `~/.config/environment.d/10-secrets.conf` or the paperclip
secrets available to the agent. The account can be a parent/guardian account or
per-child; confirmation of whether Compass enforces a one-time code on a new
device is also needed.
A reliable way past Cloudflare's managed challenge for the login POST. The
credential itself is no longer missing: it is in
`~/.config/environment.d/10-secrets.conf` as `COMPASS_USERNAME` / `COMPASS_PASSWORD`.
Options (owner: Sam, because they are host/infra or browser decisions):
1. Give the harness a **headful** Chrome (e.g. add `xvfb` to the `.13` NixOS
config and run Chrome non-headless). Managed challenges normally pass there.
2. Supply a `cf_clearance` cookie from a real browser session on the same public
IP and user agent, as a secret to inject.
3. Confirm a non-CF login path (SAML/SSO or the Compass mobile API) to pursue.
## Smallest useful scope for Phase 3
The login page exposes a username/password form and a parent one-time-code
option. Until a login succeeds we cannot enumerate the per-child page URLs, so
the smallest useful scope cannot be fixed yet. It is expected to be the
Until a login succeeds we cannot enumerate the per-child page URLs, so the
smallest useful scope cannot be fixed yet. It is expected to be the
"today/planner" page plus the messages/notices list, which is what answers "what
is overdue".

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"));
}