openvibers/openvibe.rent Commits
Rent: a crafted sign-in state can no longer crash the process; /internal/ and /metrics refused case-insensitively (#5)
Changes
@@@ -99,11 +99,14 @@ server {}# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5010/metrics) ──-- location = /metrics { return 404; }++ # Case-insensitive: Express matches routes case-insensitively, so a case-sensitive location would let++ # /Metrics through to the app.++ location ~* ^/metrics(/|$) { return 404; }# ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──# Events posts to http://127.0.0.1:5010/internal/events directly. Never proxied from outside (ADR-033).-- location /internal/ { return 404; }++ # Case-insensitive: Express matches routes case-insensitively, so /Internal/events would otherwise reach the app.++ location ~* ^/internal(/|$) { return 404; }# ── Pages, docs, robots.txt, sitemap, static assets ─────location / {@@@ -14,8 +14,8 @@* or logged.*/const crypto = require('crypto');--const express = require('express');const cache = require('openvibe-shared/cache-policy');++const { asyncRouter } = require('../http/router');const ACCESS_COOKIE = 'rent_at';const REFRESH_COOKIE = 'rent_rt';@@@ -166,7 +166,9 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat}function routes() {-- const r = express.Router();++ // asyncRouter, not express.Router: a rejected handler (e.g. a state check that throws) goes to the app's++ // error handler instead of becoming an unhandled rejection that takes the process down.++ const r = asyncRouter();const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });r.get('/login', (req, res) => {@@@ -189,8 +191,13 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Datif ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);}-- if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'-- || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {++ // Compare UTF-8 byte lengths, never the UTF-16 string lengths: a multibyte state of the same string++ // length would make timingSafeEqual throw (RangeError on unequal buffer lengths). Any missing or++ // non-string piece, or unequal byte lengths, refuses before timingSafeEqual is ever called.++ const stateBuf = typeof state === 'string' ? Buffer.from(state) : null;++ const flowBuf = flow && typeof flow.state === 'string' ? Buffer.from(flow.state) : null;++ if (!flow || !flowBuf || typeof flow.verifier !== 'string' || !stateBuf++ || stateBuf.length !== flowBuf.length || !crypto.timingSafeEqual(stateBuf, flowBuf)) {return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');}if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');@@@ -55,6 +55,22 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(noCookie.status, 400);});++ await check('a multibyte state of the right string length is refused without taking the server down', async () => {++ const login = await t.get('/auth/login');++ const loc = new URL(login.headers.get('location'));++ const flowCookie = login.headers.get('set-cookie').split(';')[0];++ const realState = loc.searchParams.get('state');++ // 32 'é' (U+00E9) are 32 UTF-16 code units, as the flow state, but 64 UTF-8 bytes. Comparing string++ // lengths passed, then timingSafeEqual threw on the unequal buffers and, unhandled, killed the process.++ const multibyte = 'é'.repeat(realState.length);++ assert.strictEqual(multibyte.length, realState.length);++ const r = await t.get(`/auth/callback?code=x&state=${encodeURIComponent(multibyte)}`, { cookie: flowCookie });++ assert.strictEqual(r.status, 400, r.text);++ assert.match(r.text, /state did not match/i);++ const ping = await t.get('/api/v1/ping');++ assert.strictEqual(ping.status, 200, 'the server keeps answering after the refusal');++ });++await check('next= only accepts same-site paths', async () => {const r = await t.get('/auth/login?next=//evil.example/x');const flow = decodeURIComponent(r.headers.get('set-cookie').match(/rent_oauth=([^;]+)/)[1]);@@@ -17,4 +17,10 @@ assert.match(auth, /limit_req zone=rent_auth burst=10 nodelay;/);for (const b of [probe, auth]) assert.match(b, /limit_req_status 429;/);const upstream = (b) => (b.match(/proxy_pass (\S+);/) || [])[1];assert.ok(upstream(probe) && upstream(probe) === upstream(auth), 'the same upstream');--console.log('nginx auth limit: /auth/me on the API zone, 429 when limited');++// /internal/ and /metrics are refused case-insensitively: Express matches routes case-insensitively, so a++// case-sensitive location would let /Internal/events or /Metrics through to the app.++assert.match(conf, /location ~\* \^\/internal\(\/\|\$\) \{ return 404; \}/);++assert.match(conf, /location ~\* \^\/metrics\(\/\|\$\) \{ return 404; \}/);++assert.ok(!/location = \/metrics \{/.test(conf), 'no case-sensitive exact /metrics location');++assert.ok(!/location \/internal\/ \{/.test(conf), 'no case-sensitive /internal/ location');++console.log('nginx auth limit: /auth/me on the API zone, 429 when limited; /internal and /metrics case-insensitively refused');
@@ -99,11 +99,14 @@ server {
@@ -99,11 +99,14 @@ server {
}
}
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5010/metrics) ──
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5010/metrics) ──
- location = /metrics { return 404; }
+ # Case-insensitive: Express matches routes case-insensitively, so a case-sensitive location would let
+ # /Metrics through to the app.
+ location ~* ^/metrics(/|$) { return 404; }
# ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──
# ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──
# Events posts to http://127.0.0.1:5010/internal/events directly. Never proxied from outside (ADR-033).
# Events posts to http://127.0.0.1:5010/internal/events directly. Never proxied from outside (ADR-033).
- location /internal/ { return 404; }
+ # Case-insensitive: Express matches routes case-insensitively, so /Internal/events would otherwise reach the app.
+ location ~* ^/internal(/|$) { return 404; }
# ── Pages, docs, robots.txt, sitemap, static assets ─────
# ── Pages, docs, robots.txt, sitemap, static assets ─────
location / {
location / {
@@ -14,8 +14,8 @@
@@ -14,8 +14,8 @@
* or logged.
* or logged.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
-const express = require('express');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
+const { asyncRouter } = require('../http/router');
const ACCESS_COOKIE = 'rent_at';
const ACCESS_COOKIE = 'rent_at';
const REFRESH_COOKIE = 'rent_rt';
const REFRESH_COOKIE = 'rent_rt';
@@ -166,7 +166,9 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -166,7 +166,9 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
}
}
function routes() {
function routes() {
- const r = express.Router();
+ // asyncRouter, not express.Router: a rejected handler (e.g. a state check that throws) goes to the app's
+ // error handler instead of becoming an unhandled rejection that takes the process down.
+ const r = asyncRouter();
const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });
const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });
r.get('/login', (req, res) => {
r.get('/login', (req, res) => {
@@ -189,8 +191,13 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -189,8 +191,13 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);
if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);
return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);
return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);
}
}
- if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'
- || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {
+ // Compare UTF-8 byte lengths, never the UTF-16 string lengths: a multibyte state of the same string
+ // length would make timingSafeEqual throw (RangeError on unequal buffer lengths). Any missing or
+ // non-string piece, or unequal byte lengths, refuses before timingSafeEqual is ever called.
+ const stateBuf = typeof state === 'string' ? Buffer.from(state) : null;
+ const flowBuf = flow && typeof flow.state === 'string' ? Buffer.from(flow.state) : null;
+ if (!flow || !flowBuf || typeof flow.verifier !== 'string' || !stateBuf
+ || stateBuf.length !== flowBuf.length || !crypto.timingSafeEqual(stateBuf, flowBuf)) {
return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');
return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');
}
}
if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
@@ -55,6 +55,22 @@ const { boot, check, done } = require('./helpers/boot');
@@ -55,6 +55,22 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(noCookie.status, 400);
assert.strictEqual(noCookie.status, 400);
});
});
+ await check('a multibyte state of the right string length is refused without taking the server down', async () => {
+ const login = await t.get('/auth/login');
+ const loc = new URL(login.headers.get('location'));
+ const flowCookie = login.headers.get('set-cookie').split(';')[0];
+ const realState = loc.searchParams.get('state');
+ // 32 'é' (U+00E9) are 32 UTF-16 code units, as the flow state, but 64 UTF-8 bytes. Comparing string
+ // lengths passed, then timingSafeEqual threw on the unequal buffers and, unhandled, killed the process.
+ const multibyte = 'é'.repeat(realState.length);
+ assert.strictEqual(multibyte.length, realState.length);
+ const r = await t.get(`/auth/callback?code=x&state=${encodeURIComponent(multibyte)}`, { cookie: flowCookie });
+ assert.strictEqual(r.status, 400, r.text);
+ assert.match(r.text, /state did not match/i);
+ const ping = await t.get('/api/v1/ping');
+ assert.strictEqual(ping.status, 200, 'the server keeps answering after the refusal');
+ });
+
await check('next= only accepts same-site paths', async () => {
await check('next= only accepts same-site paths', async () => {
const r = await t.get('/auth/login?next=//evil.example/x');
const r = await t.get('/auth/login?next=//evil.example/x');
const flow = decodeURIComponent(r.headers.get('set-cookie').match(/rent_oauth=([^;]+)/)[1]);
const flow = decodeURIComponent(r.headers.get('set-cookie').match(/rent_oauth=([^;]+)/)[1]);
@@ -17,4 +17,10 @@ assert.match(auth, /limit_req zone=rent_auth burst=10 nodelay;/);
@@ -17,4 +17,10 @@ assert.match(auth, /limit_req zone=rent_auth burst=10 nodelay;/);
for (const b of [probe, auth]) assert.match(b, /limit_req_status 429;/);
for (const b of [probe, auth]) assert.match(b, /limit_req_status 429;/);
const upstream = (b) => (b.match(/proxy_pass (\S+);/) || [])[1];
const upstream = (b) => (b.match(/proxy_pass (\S+);/) || [])[1];
assert.ok(upstream(probe) && upstream(probe) === upstream(auth), 'the same upstream');
assert.ok(upstream(probe) && upstream(probe) === upstream(auth), 'the same upstream');
-console.log('nginx auth limit: /auth/me on the API zone, 429 when limited');
+// /internal/ and /metrics are refused case-insensitively: Express matches routes case-insensitively, so a
+// case-sensitive location would let /Internal/events or /Metrics through to the app.
+assert.match(conf, /location ~\* \^\/internal\(\/\|\$\) \{ return 404; \}/);
+assert.match(conf, /location ~\* \^\/metrics\(\/\|\$\) \{ return 404; \}/);
+assert.ok(!/location = \/metrics \{/.test(conf), 'no case-sensitive exact /metrics location');
+assert.ok(!/location \/internal\/ \{/.test(conf), 'no case-sensitive /internal/ location');
+console.log('nginx auth limit: /auth/me on the API zone, 429 when limited; /internal and /metrics case-insensitively refused');