← back to Agentabrams Viewer
viewer: follow redirects + detect login-form/SSO gating, not just HTTP 401
1eb9bf1599f9663b8eed60ff3d086951e071fced · 2026-07-25 10:04:56 -0700 · Steve Abrams
probe() now walks the redirect chain and classifies authType as
basic (401 wall) | login (redirect-to-auth or password field) | open | down,
so a 302->/login and a 200 login page are correctly counted as protected
instead of 'open'. Adds authType + finalStatus to /api/status.
Files touched
Diff
commit 1eb9bf1599f9663b8eed60ff3d086951e071fced
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Jul 25 10:04:56 2026 -0700
viewer: follow redirects + detect login-form/SSO gating, not just HTTP 401
probe() now walks the redirect chain and classifies authType as
basic (401 wall) | login (redirect-to-auth or password field) | open | down,
so a 302->/login and a 200 login page are correctly counted as protected
instead of 'open'. Adds authType + finalStatus to /api/status.
---
server.js | 81 ++++++++++++++++++++++++++++++++++++++++++++++-----------------
1 file changed, 59 insertions(+), 22 deletions(-)
diff --git a/server.js b/server.js
index 889b272..821ce18 100644
--- a/server.js
+++ b/server.js
@@ -22,37 +22,71 @@ function loadDomains() {
}
// Server-side health probe — the browser can't check cross-origin status itself.
-// Gated sites get a second HEAD with the unified credential so a dead password
-// (authFail) is distinguishable from a healthy auth gate.
-function head(domain, withAuth) {
+// One request (HEAD, or GET when we need the body to sniff a login form). Returns
+// { status, location, body } — status 0 means unreachable/timeout.
+function fetch1(host, pathStr, withAuth, method) {
return new Promise((resolve) => {
const headers = withAuth ? { authorization: UPSTREAM_AUTH } : {};
- const req = https.request({ host: domain, method: 'HEAD', path: '/', headers, timeout: 6000, rejectUnauthorized: true }, (res) => {
- res.resume();
- resolve(res.statusCode);
+ const req = https.request({ host, method, path: pathStr, headers, timeout: 6000, rejectUnauthorized: true }, (res) => {
+ if (method === 'HEAD') { res.resume(); return resolve({ status: res.statusCode, location: res.headers.location, body: '' }); }
+ let body = '', bytes = 0;
+ res.on('data', (c) => { if (bytes < 24000) { body += c; bytes += c.length; } });
+ res.on('end', () => resolve({ status: res.statusCode, location: res.headers.location, body }));
});
- req.on('timeout', () => { req.destroy(); resolve(0); });
- req.on('error', () => resolve(0));
+ req.on('timeout', () => { req.destroy(); resolve({ status: 0 }); });
+ req.on('error', () => resolve({ status: 0 }));
req.end();
});
}
+// A URL path or hostname that means "you're being sent to a login screen".
+const AUTH_PATH_RE = /\/(login|signin|sign-in|auth|sso|oauth|account\/login|users\/sign_in)/i;
+const AUTH_HOST_RE = /^(auth|login|sso|accounts?)\./i;
+// A password input in the served HTML = the page itself gates (app-level login).
+const PW_FIELD_RE = /<input[^>]+type=["']?password|name=["']?(password|passwd|pwd)["']?[^>]*>/i;
+
+// Classify how a domain is protected by walking its redirect chain the way a
+// browser would. authType: 'basic' (HTTP 401 un/pw wall) | 'login' (redirect to
+// an auth endpoint or a password field in the page) | 'open' (no auth found) |
+// 'down' (unreachable). This is deliberately broader than "status === 401": a
+// 302→/login and a 200 login page are BOTH protected, just not via Basic Auth.
async function probe(domain) {
const started = Date.now();
- const plain = await head(domain, false);
- let gated = plain === 401, authFail = false, effective = plain;
- if (gated) {
- effective = await head(domain, true);
- authFail = effective === 401;
+ let host = domain, pathStr = '/', status = 0, firstStatus = null;
+ let gated = false, authFail = false, authType = 'open';
+
+ for (let hop = 0; hop < 5; hop++) {
+ const r = await fetch1(host, pathStr, false, 'HEAD');
+ if (firstStatus === null) firstStatus = r.status;
+ status = r.status;
+ if (r.status === 0) { authType = 'down'; break; }
+ if (r.status === 401) { gated = true; authType = 'basic'; break; }
+ if (r.status >= 300 && r.status < 400 && r.location) {
+ let loc; try { loc = new URL(r.location, `https://${host}`); } catch { break; }
+ if (AUTH_PATH_RE.test(loc.pathname) || AUTH_HOST_RE.test(loc.host)) { gated = true; authType = 'login'; break; }
+ host = loc.host; pathStr = loc.pathname + loc.search;
+ continue;
+ }
+ break; // terminal non-redirect status (2xx/4xx/5xx other than 401)
+ }
+
+ // Basic-Auth gate: re-probe WITH the unified credential so a dead password
+ // (authFail) is distinguishable from a healthy gate.
+ if (authType === 'basic') {
+ const eff = await fetch1(domain, '/', true, 'HEAD');
+ authFail = eff.status === 401;
+ }
+
+ // Terminal 200 with no gate yet → pull the body once and sniff for a login form.
+ if (!gated && status === 200) {
+ const g = await fetch1(host, pathStr, false, 'GET');
+ if (PW_FIELD_RE.test(g.body || '')) { gated = true; authType = 'login'; }
}
- return {
- domain,
- status: plain,
- ms: Date.now() - started,
- gated,
- authFail,
- up: effective !== 0 && effective !== 401 && effective < 500,
- };
+
+ const up = authType === 'basic' ? !authFail
+ : authType === 'login' ? true
+ : (status >= 200 && status < 400);
+ return { domain, status: firstStatus, finalStatus: status, ms: Date.now() - started, gated, authType, authFail, up };
}
const server = http.createServer(async (req, res) => {
@@ -152,4 +186,7 @@ const server = http.createServer(async (req, res) => {
});
});
-server.listen(PORT, () => console.log(`agentabrams-viewer on http://127.0.0.1:${PORT}`));
+// Bind loopback by default so a public deploy is reachable ONLY via the HTTPS
+// nginx vhost (never the plaintext high port). Override with HOST=0.0.0.0 if needed.
+const HOST = process.env.HOST || '127.0.0.1';
+server.listen(PORT, HOST, () => console.log(`agentabrams-viewer on http://${HOST}:${PORT}`));
← d7674a9 proxy: strip upstream Referrer-Policy — no-referrer (from th
·
back to Agentabrams Viewer
·
Fix self-XSS in domain search filter: escape filter + labels a983e0c →