conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

commit e45ddafc1349169b09ebb28a4d049961f06d4773
parent f55d8d39bc2f992264e889655daec9833eae68d8
Author: finwo <finwo@pm.me>
Date:   Sat, 26 Sep 2026 18:10:46 +0200

Say which origins are registered when a redirect uri is refused

Diffstat:
Mconductor.example.yaml | 5+++--
Mdocs/auth.md | 19++++++++++++++-----
Msrc/conductor/auth/index.js | 28++++++++++++++++++++--------
Msrc/lib/config.js | 19++++++++++++++++++-
Mtest/config.test.js | 14++++++++++++++
Mtest/helpers/harness.js | 3+++
Mtest/provider.test.js | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 129 insertions(+), 16 deletions(-)

diff --git a/conductor.example.yaml b/conductor.example.yaml @@ -52,8 +52,9 @@ auth: # setting it true uses the built-in one even with a provider configured. local: enabled: null - # Where the app is allowed to be sent back to, when it is not served - # from server.public_url. Comma separated. + # Where the app may be sent back to, when it is not served from + # server.public_url. Comma separated absolute URLs, compared by origin. + # "*" allows any, which is for local testing only. redirect_uris: null # Setting a discovery url switches authentication to the provider. Built-in # accounts are used when it is unset. Everything else about the provider, diff --git a/docs/auth.md b/docs/auth.md @@ -126,11 +126,20 @@ id derived from the code's own id, so redeeming it twice collides on the primary key and is refused. Codes are single use without a table of spent codes. -`auth.local.redirect_uris` is where the browser may be sent back to. It -defaults to `server.public_url` and is compared by origin, so an -application served from somewhere else needs its origin listed. A redirect -uri that is not listed is refused outright rather than redirected to, or -this would be an open redirect. +`auth.local.redirect_uris` is where the browser may be sent back to. It is +a comma separated list of absolute URLs, compared by origin, and defaults +to `server.public_url`. A redirect uri that is not listed is refused +outright rather than redirected to, or this would be an open redirect. The +refusal names what is registered, because `localhost` and `127.0.0.1` are +different origins and that is otherwise a long afternoon. + +```sh +CONDUCTOR_LOCAL_REDIRECT_URIS=http://127.0.0.1:8080,http://localhost:8080 +``` + +An entry without a scheme is refused at startup rather than silently +matching nothing. `*` allows any http or https origin, which is meant for +local testing and should not be anywhere near a deployment. Using an external provider -------------------------- diff --git a/src/conductor/auth/index.js b/src/conductor/auth/index.js @@ -71,7 +71,23 @@ export default async function authRoutes(fastify, { cfg, auth, users, refreshTok done(null, out); }); - const allowed = (uri) => cfg.auth.local.redirect_uris.some((entry) => sameOrigin(uri, entry)); + const registered = cfg.auth.local.redirect_uris; + const anywhere = registered.includes('*'); + + function allowed(uri) { + if (typeof uri !== 'string' || uri.length === 0) return false; + if (anywhere) return /^https?:/.test(uri); + return registered.some((entry) => sameOrigin(uri, entry)); + } + + function refuse(reply, what, given) { + return reply.code(400).type('text/plain; charset=utf-8').send( + `${what} ${JSON.stringify(given ?? null)} is not registered for this conductor.\n` + + `Registered: ${registered.join(', ')}\n` + + 'Set auth.local.redirect_uris, or CONDUCTOR_LOCAL_REDIRECT_URIS, to a comma separated\n' + + 'list of origins. Use * to allow any, which is for local testing only.\n' + ); + } function oauthError(reply, status, error, description) { return reply.code(status).send({ error, error_description: description }); @@ -83,9 +99,8 @@ export default async function authRoutes(fastify, { cfg, auth, users, refreshTok const { client_id: clientId, redirect_uri: redirectUri, response_type: responseType } = req.query; const { code_challenge: challenge, code_challenge_method: method, state } = req.query; - if (typeof redirectUri !== 'string' || !allowed(redirectUri)) { - reply.code(400).type('text/plain; charset=utf-8') - .send('redirect_uri is not registered for this conductor'); + if (!allowed(redirectUri)) { + refuse(reply, 'redirect_uri', redirectUri); return null; } @@ -233,10 +248,7 @@ export default async function authRoutes(fastify, { cfg, auth, users, refreshTok fastify.get('/auth/logout', async (req, reply) => { const target = req.query.post_logout_redirect_uri; if (typeof target === 'string' && target.length > 0) { - if (!allowed(target)) { - return reply.code(400).type('text/plain; charset=utf-8') - .send('post_logout_redirect_uri is not registered for this conductor'); - } + if (!allowed(target)) return refuse(reply, 'post_logout_redirect_uri', target); return reply.redirect(target, 303); } return reply.redirect(issuer, 303); diff --git a/src/lib/config.js b/src/lib/config.js @@ -207,7 +207,8 @@ function deepFreeze(obj) { function redirectUris(value, publicUrl) { const given = Array.isArray(value) ? value : String(value ?? '').split(','); const uris = given.map((u) => String(u).trim().replace(/\/+$/, '')).filter(Boolean); - return uris.length > 0 ? uris : [publicUrl.replace(/\/+$/, '')]; + if (uris.length === 0) return [publicUrl.replace(/\/+$/, '')]; + return uris.includes('*') ? ['*'] : uris; } // Reads a 32 byte key given as hex or base64. Returns a Buffer, or null. @@ -262,6 +263,22 @@ function validate(cfg) { errors.push('auth.local.enabled is false and no auth.oidc.discovery_url is set, so nobody could sign in'); } + // A bare host:port parses with a nonsense scheme and would match nothing. + for (const uri of redirectUris(cfg.auth.local.redirect_uris, cfg.server.public_url)) { + if (uri === '*') continue; + let parsed = null; + try { + parsed = new URL(uri); + } catch { /* reported below */ } + + if (!parsed || !parsed.protocol.startsWith('http')) { + errors.push( + `auth.local.redirect_uris entry ${JSON.stringify(uri)} must be an absolute http or https URL, ` + + 'such as https://ci.example.com, or * to allow any origin' + ); + } + } + try { new URL(cfg.server.public_url); } catch { diff --git a/test/config.test.js b/test/config.test.js @@ -122,6 +122,20 @@ test('redirect uris are split on commas and lose trailing slashes', async () => }); }); +test('a star allows any origin, and absorbs anything listed beside it', async () => { + await withEnv({ CONDUCTOR_LOCAL_REDIRECT_URIS: 'https://app.example.com,*' }, () => { + assert.deepEqual(loadConfig().auth.local.redirect_uris, ['*']); + }); +}); + +// A bare host:port parses as a URL with a nonsense scheme, so it would +// silently match nothing rather than failing. +test('a redirect uri without a scheme is rejected at startup', async () => { + await withEnv({ CONDUCTOR_LOCAL_REDIRECT_URIS: '127.0.0.1:18080,localhost:18080' }, () => { + assert.throws(() => loadConfig(), /must be an absolute http or https URL/); + }); +}); + test('token lifetimes are read from the environment as integers', async () => { await withEnv({ CONDUCTOR_ACCESS_TTL: '300', diff --git a/test/helpers/harness.js b/test/helpers/harness.js @@ -108,6 +108,9 @@ export async function startHarness(options = {}) { : []), 'auth:', ' session_secret: test-session-secret-not-for-real-use', + ...(options.redirectUris + ? [' local:', ` redirect_uris: ${JSON.stringify(options.redirectUris)}`] + : []), ...(options.oidcDiscoveryUrl ? [ ' oidc:', diff --git a/test/provider.test.js b/test/provider.test.js @@ -148,6 +148,63 @@ test('an unregistered redirect uri is refused without redirecting', async () => }); }); +// localhost and 127.0.0.1 are different origins, which is the mistake this +// message has to be able to explain. +test('a refused redirect uri names what is registered', async () => { + await withConductor(async (c) => { + const query = authorizeQuery({ redirect_uri: 'http://localhost:8080/' }); + const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` }); + + assert.equal(res.statusCode, 400); + assert.match(res.body, /localhost:8080/); + assert.match(res.body, /Registered: http:\/\/conductor\.test/); + assert.match(res.body, /CONDUCTOR_LOCAL_REDIRECT_URIS/); + }); +}); + +test('several origins may be registered at once', async () => { + const c = await startHarness({ + bootstrap: true, + publicUrl: PUBLIC_URL, + redirectUris: 'http://127.0.0.1:18080,http://localhost:18080', + }); + try { + for (const origin of ['http://127.0.0.1:18080', 'http://localhost:18080']) { + const query = authorizeQuery({ redirect_uri: `${origin}/` }); + const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` }); + assert.equal(res.statusCode, 200, `${origin} should be registered`); + } + + // Anything outside the list is still refused. + for (const origin of ['http://127.0.0.1:19999', 'https://evil.example']) { + const query = authorizeQuery({ redirect_uri: `${origin}/` }); + const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` }); + assert.equal(res.statusCode, 400, `${origin} should be refused`); + } + } finally { + await c.stop(); + } +}); + +test('a star registration allows any origin but still refuses nonsense', async () => { + const c = await startHarness({ bootstrap: true, publicUrl: PUBLIC_URL, redirectUris: '*' }); + try { + for (const uri of ['http://localhost:3000/', 'https://anything.example/callback']) { + const query = authorizeQuery({ redirect_uri: uri }); + const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` }); + assert.equal(res.statusCode, 200, `${uri} should be allowed`); + } + + for (const uri of ['javascript:alert(1)', 'data:text/html,x', '']) { + const query = authorizeQuery({ redirect_uri: uri }); + const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` }); + assert.equal(res.statusCode, 400, `${JSON.stringify(uri)} must still be refused`); + } + } finally { + await c.stop(); + } +}); + test('a bad request with a good redirect uri comes back as an oauth error', async () => { await withConductor(async (c) => { for (const [override, error] of [