The button did nothing. It claimed a blank tab during the click and pointed it at the login URL once the request returned — the standard way around a popup blocker, and it cannot work in this app: helmet sends Cross-Origin-Opener-Policy: same-origin, which severs the handle to that tab the moment it goes cross-origin. Assigning its location was a no-op. A blank tab opened, nothing else happened. The handle was never needed. window.open with 'noopener' asks for none, and a click's user activation outlives the fetch, so the browser does not treat it as a popup. The status line now also carries the sign-in URL as an ordinary link, so there is a way through whatever any particular browser decides about opening windows. Verified the server side against the real Nextcloud first: the flow starts, both returned URLs pass the SSRF guard and the same-host check. The fault was entirely in the browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
115 lines
6.2 KiB
JavaScript
115 lines
6.2 KiB
JavaScript
// Nextcloud's own Login Flow v2: the person signs in on Nextcloud however they
|
|
// normally do — SSO, 2FA, a password manager — and Nextcloud hands back an app
|
|
// password it generated. We never see their real password and they never have
|
|
// to find the app-password screen.
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
|
|
const route = read('src/routes/nextcloud.js');
|
|
const ui = read('public/js/nextcloud.js');
|
|
const start = route.slice(route.indexOf("router.post('/nextcloud/login-flow/start'"),
|
|
route.indexOf("router.post('/nextcloud/login-flow/poll'"));
|
|
const poll = route.slice(route.indexOf("router.post('/nextcloud/login-flow/poll'"),
|
|
route.indexOf("router.post('/nextcloud/connect'"));
|
|
|
|
test('the URLs the server hands back are checked, not followed on trust', () => {
|
|
// The remote server chooses both the login URL and the poll endpoint. An
|
|
// endpoint pointing elsewhere would make this a request-forgery gadget aimed
|
|
// at whatever it named, with our credentials attached.
|
|
assert.match(start, /assertSafeHttpsUrl\(cleanUrl, 'Nextcloud URL'\)/);
|
|
assert.match(start, /assertSafeHttpsUrl\(poll\.endpoint, 'Nextcloud poll endpoint'\)/);
|
|
assert.match(start, /assertSafeHttpsUrl\(loginUrl, 'Nextcloud login URL'\)/);
|
|
// And both must be on the host the person actually typed.
|
|
assert.match(start, /new URL\(poll\.endpoint\)\.host !== origin \|\| new URL\(loginUrl\)\.host !== origin/);
|
|
assert.match(start, /pointed the login somewhere else/);
|
|
});
|
|
|
|
test('the poll token never reaches the browser', () => {
|
|
// It is a credential. The browser gets an opaque handle instead.
|
|
assert.match(start, /res\.json\(\{ success: true, handle: handle, loginUrl: loginUrl \}\)/);
|
|
assert.doesNotMatch(start, /res\.json\([^)]*token/);
|
|
const flowHandler = ui.slice(ui.indexOf('btn-nc-login-flow'));
|
|
assert.match(flowHandler, /handle: data\.handle/);
|
|
// The manual fallback above it does send an app password — that is its whole
|
|
// point. This path must not.
|
|
assert.doesNotMatch(flowHandler, /poll\.token|appPassword/);
|
|
});
|
|
|
|
test('a handle belongs to the account that started the flow', () => {
|
|
assert.match(poll, /!flow \|\| flow\.owner !== req\.user\.id/);
|
|
assert.match(poll, /a handle is not a bearer token/);
|
|
});
|
|
|
|
test('"not finished yet" is a normal answer, not a failure', () => {
|
|
// Nextcloud answers 404 while the person is still typing their password.
|
|
assert.match(poll, /if \(status === 404\) return res\.json\(\{ success: true, pending: true \}\)/);
|
|
});
|
|
|
|
test('the server Nextcloud reports is re-checked before it is stored', () => {
|
|
assert.match(poll, /answer\.data\.server \|\| flow\.url/);
|
|
assert.match(poll, /assertSafeHttpsUrl\(serverUrl, 'Nextcloud URL'\)/);
|
|
});
|
|
|
|
test('the app password is encrypted at rest, and bound to the row it is stored in', () => {
|
|
// Bound, not merely encrypted: an unbound ciphertext copied onto another
|
|
// user's row decrypts there, and that account's exports land in someone
|
|
// else's storage. See test/crypto-context-binding.test.js.
|
|
assert.match(poll, /cryptoUtil\.encryptString\(appPassword, tokenContext\(req\.user\.id\)\)/);
|
|
});
|
|
|
|
test('a flow expires, and starting again replaces the old one', () => {
|
|
assert.match(route, /LOGIN_FLOW_TTL_MS = 20 \* 60 \* 1000/);
|
|
assert.match(route, /function sweepLoginFlows\(\)/);
|
|
assert.match(start, /flow\.owner === req\.user\.id && key !== handle\) loginFlows\.delete\(key\)/);
|
|
});
|
|
|
|
test('the sign-in tab is opened without a handle, which COOP would sever', () => {
|
|
// The original claimed a blank tab during the click and pointed it at the URL
|
|
// when the request came back — the usual way around a popup blocker, and
|
|
// broken here: this app sends Cross-Origin-Opener-Policy: same-origin, so the
|
|
// handle dies as soon as the tab goes cross-origin and assigning its location
|
|
// did nothing. A blank tab, and a button that looked dead.
|
|
const handler = ui.slice(ui.indexOf("btn-nc-login-flow"));
|
|
assert.doesNotMatch(handler, /window\.open\(''/, 'no blank tab claimed up front');
|
|
assert.doesNotMatch(handler, /tab\.location/, 'no handle to navigate');
|
|
assert.match(handler, /window\.open\(data\.loginUrl, '_blank', 'noopener'\)/);
|
|
});
|
|
|
|
test('the sign-in URL is also offered as a real link', () => {
|
|
// Whatever the browser decides about opening windows, there is a way through.
|
|
const handler = ui.slice(ui.indexOf("btn-nc-login-flow"));
|
|
assert.match(handler, /setFlowStatus\('Waiting for you to finish signing in\.', null, data\.loginUrl\)/);
|
|
assert.match(ui, /a\.rel = 'noopener noreferrer'/);
|
|
assert.match(ui, /a\.target = '_blank'/);
|
|
// Built as DOM, not spliced into innerHTML.
|
|
assert.match(ui, /function setFlowStatus\(message, tone, link\)[\s\S]{0,400}createElement\('a'\)/);
|
|
});
|
|
|
|
test('helmet still sends the COOP header this works around', () => {
|
|
// If this ever stops being true the workaround is harmless, but the comment
|
|
// explaining it would be wrong, and the old pattern would look safe again.
|
|
const server = read('server.js');
|
|
assert.match(server, /app\.use\(helmet\(\{/);
|
|
assert.doesNotMatch(server, /crossOriginOpenerPolicy:\s*false/,
|
|
'COOP is on by default in helmet; turning it off would need its own reasoning');
|
|
});
|
|
|
|
test('polling stops: on success, on failure, and on a deadline', () => {
|
|
const handler = ui.slice(ui.indexOf("btn-nc-login-flow"));
|
|
assert.match(handler, /Date\.now\(\) > until\) return stopPolling/);
|
|
assert.match(handler, /if \(result\.connected\)/);
|
|
// stopPolling owns the interval, and is what every exit calls.
|
|
assert.match(ui, /function stopPolling\(message, tone\)[\s\S]{0,160}clearInterval\(pollTimer\)/);
|
|
// A dropped poll is not a failed sign-in.
|
|
assert.match(handler, /a dropped poll is not a failed sign-in/);
|
|
});
|
|
|
|
test('the app-password path is still there, as the fallback', () => {
|
|
assert.match(route, /router\.post\('\/nextcloud\/connect'/);
|
|
const settings = read('public/components/settings.html');
|
|
assert.match(settings, /Use an app password instead/);
|
|
assert.match(settings, /id="btn-nc-login-flow"/);
|
|
});
|