Address CodeQL findings on the OIDC auth flow
- Open redirect / client-side XSS (app-base.js): the post-login `redirect` read from the URL fragment was assigned straight to window.location. Add a same-origin guard (safeInternalPath) that rejects absolute URLs, protocol-relative "//host"/"/\\host", and scheme targets like "javascript:". Apply it in consumeTokenFragment and logInRedirect. - Server-side defense in depth: sanitize `redirect` when storing OidcState and when building the callback fragment (utils/safe_redirect.js, shared + unit-tested). - Missing rate limiting: throttle the unauthenticated auth endpoints (/login, /oidc/start, /oidc/callback) with express-rate-limit (60/IP/15m). Set `trust proxy: 1` so req.ip reflects the real client behind OpenResty. Adds test/unit/safe_redirect.test.js; unit suite 77 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,11 @@ const express = require('express');
|
|||||||
// Set up the express app.
|
// Set up the express app.
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// The app always runs behind the OpenResty reverse proxy (a single hop) which
|
||||||
|
// sets X-Real-IP / X-Forwarded-For. Trust that one proxy so req.ip reflects the
|
||||||
|
// real client — needed for correct per-client rate limiting on /api/auth.
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
// Hold list of functions to run when the server is ready
|
// Hold list of functions to run when the server is ready
|
||||||
app.onListen = [];
|
app.onListen = [];
|
||||||
|
|
||||||
|
|||||||
Generated
+28
@@ -18,6 +18,7 @@
|
|||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"ejs": "^6.0.1",
|
"ejs": "^6.0.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
"jq-repeat": "^2.0.1",
|
"jq-repeat": "^2.0.1",
|
||||||
"jquery": "^4.0.0",
|
"jquery": "^4.0.0",
|
||||||
@@ -917,6 +918,24 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "8.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||||
|
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ip-address": "^10.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/extend": {
|
"node_modules/extend": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
@@ -1229,6 +1248,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
|||||||
+4
-3
@@ -11,10 +11,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
"dev": "npx nodemon --ignore public/ ./bin/www",
|
"dev": "npx nodemon --ignore public/ ./bin/www",
|
||||||
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js",
|
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js",
|
||||||
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/unix_socket.test.js",
|
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/unix_socket.test.js",
|
||||||
"test:integration": "node --test test/integration/dns_provider.test.js",
|
"test:integration": "node --test test/integration/dns_provider.test.js",
|
||||||
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js"
|
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"ejs": "^6.0.1",
|
"ejs": "^6.0.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
"jq-repeat": "^2.0.1",
|
"jq-repeat": "^2.0.1",
|
||||||
"jquery": "^4.0.0",
|
"jquery": "^4.0.0",
|
||||||
|
|||||||
@@ -200,6 +200,17 @@ app.auth = (function(app){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Constrain a redirect target to a same-origin absolute path. Rejects
|
||||||
|
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
|
||||||
|
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
|
||||||
|
function safeInternalPath(path){
|
||||||
|
if(typeof path !== 'string' || path.charAt(0) !== '/'
|
||||||
|
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
// Consume an app token handed back by the OIDC callback via the URL
|
// Consume an app token handed back by the OIDC callback via the URL
|
||||||
// fragment (#token=…&redirect=…). Stores it, strips the fragment, and
|
// fragment (#token=…&redirect=…). Stores it, strips the fragment, and
|
||||||
// forwards to the intended page. Returns true if a token was consumed.
|
// forwards to the intended page. Returns true if a token was consumed.
|
||||||
@@ -210,7 +221,9 @@ app.auth = (function(app){
|
|||||||
if(!token) return false;
|
if(!token) return false;
|
||||||
|
|
||||||
setToken(token);
|
setToken(token);
|
||||||
var redirect = params.get('redirect') || '/';
|
// redirect comes from the URL fragment (attacker-controllable); only
|
||||||
|
// allow a same-origin path so it can't become an open redirect / XSS.
|
||||||
|
var redirect = safeInternalPath(params.get('redirect') || '/');
|
||||||
// Drop the token from the address bar before navigating on.
|
// Drop the token from the address bar before navigating on.
|
||||||
history.replaceState(null, '', location.pathname + location.search);
|
history.replaceState(null, '', location.pathname + location.search);
|
||||||
window.location.href = redirect;
|
window.location.href = redirect;
|
||||||
@@ -248,7 +261,7 @@ app.auth = (function(app){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function logInRedirect(){
|
function logInRedirect(){
|
||||||
window.location.href = location.href.replace(location.origin+'/login', '') || '/'
|
window.location.href = safeInternalPath(location.href.replace(location.origin+'/login', '') || '/')
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+19
-5
@@ -1,13 +1,25 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
|
const { rateLimit } = require('express-rate-limit');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const { Auth } = require('../models/auth');
|
const { Auth } = require('../models/auth');
|
||||||
const { OidcState } = require('../models/oidc_state');
|
const { OidcState } = require('../models/oidc_state');
|
||||||
const oidc = require('../utils/oidc');
|
const oidc = require('../utils/oidc');
|
||||||
|
const { safeInternalPath } = require('../utils/safe_redirect');
|
||||||
|
|
||||||
|
// Throttle unauthenticated auth endpoints (credential login + the OIDC
|
||||||
|
// handshake) to blunt brute-force / callback abuse. Keyed per IP.
|
||||||
|
const authLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 60, // 60 attempts per IP per window
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: {name: 'TooManyRequests', message: 'Too many attempts, please try again later.'},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
router.post('/login', async function(req, res, next){
|
router.post('/login', authLimiter, async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
let auth = await Auth.login(req.body);
|
let auth = await Auth.login(req.body);
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -36,7 +48,7 @@ router.all('/logout', async function(req, res, next){
|
|||||||
* OIDC login start: create a PKCE + state challenge, persist it (auto-expiring
|
* OIDC login start: create a PKCE + state challenge, persist it (auto-expiring
|
||||||
* via OidcState TTL), and redirect the browser to the SSO authorize endpoint.
|
* via OidcState TTL), and redirect the browser to the SSO authorize endpoint.
|
||||||
*/
|
*/
|
||||||
router.get('/oidc/start', async function(req, res, next){
|
router.get('/oidc/start', authLimiter, async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
if(!conf.oidc || !conf.oidc.enabled){
|
if(!conf.oidc || !conf.oidc.enabled){
|
||||||
let error = new Error('OidcDisabled');
|
let error = new Error('OidcDisabled');
|
||||||
@@ -49,7 +61,9 @@ router.get('/oidc/start', async function(req, res, next){
|
|||||||
await OidcState.create({
|
await OidcState.create({
|
||||||
state,
|
state,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
redirect: req.query.redirect || '/',
|
// Sanitize now so a hostile ?redirect= can't be stored and later
|
||||||
|
// reflected into the login page's navigation.
|
||||||
|
redirect: safeInternalPath(req.query.redirect || '/'),
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.redirect(oidc.buildAuthUrl(state, codeChallenge));
|
return res.redirect(oidc.buildAuthUrl(state, codeChallenge));
|
||||||
@@ -64,7 +78,7 @@ router.get('/oidc/start', async function(req, res, next){
|
|||||||
* the app token back to the browser via a URL fragment for the login page to
|
* the app token back to the browser via a URL fragment for the login page to
|
||||||
* store in localStorage.
|
* store in localStorage.
|
||||||
*/
|
*/
|
||||||
router.get('/oidc/callback', async function(req, res, next){
|
router.get('/oidc/callback', authLimiter, async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
let {code, state} = req.query;
|
let {code, state} = req.query;
|
||||||
if(!code || !state){
|
if(!code || !state){
|
||||||
@@ -85,7 +99,7 @@ router.get('/oidc/callback', async function(req, res, next){
|
|||||||
|
|
||||||
let {token} = await Auth.oidcSession(identity);
|
let {token} = await Auth.oidcSession(identity);
|
||||||
|
|
||||||
let redirect = saved.redirect || '/';
|
let redirect = safeInternalPath(saved.redirect || '/');
|
||||||
return res.redirect(
|
return res.redirect(
|
||||||
`/login#token=${encodeURIComponent(token.token)}&redirect=${encodeURIComponent(redirect)}`
|
`/login#token=${encodeURIComponent(token.token)}&redirect=${encodeURIComponent(redirect)}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {describe, test} = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
const {safeInternalPath} = require('../../utils/safe_redirect');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* safeInternalPath guards the OIDC post-login redirect against open-redirect
|
||||||
|
* and script-scheme (XSS) targets. Only same-origin "/path" values pass.
|
||||||
|
*/
|
||||||
|
describe('safeInternalPath', () => {
|
||||||
|
|
||||||
|
test('allows plain same-origin paths', () => {
|
||||||
|
assert.strictEqual(safeInternalPath('/'), '/');
|
||||||
|
assert.strictEqual(safeInternalPath('/hosts'), '/hosts');
|
||||||
|
assert.strictEqual(safeInternalPath('/dns?x=1'), '/dns?x=1');
|
||||||
|
assert.strictEqual(safeInternalPath('/a/b/c#frag'), '/a/b/c#frag');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects absolute URLs', () => {
|
||||||
|
assert.strictEqual(safeInternalPath('https://evil.com'), '/');
|
||||||
|
assert.strictEqual(safeInternalPath('http://evil.com/x'), '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects protocol-relative and backslash host tricks', () => {
|
||||||
|
assert.strictEqual(safeInternalPath('//evil.com'), '/');
|
||||||
|
assert.strictEqual(safeInternalPath('/\\evil.com'), '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects script / data schemes', () => {
|
||||||
|
assert.strictEqual(safeInternalPath('javascript:alert(1)'), '/');
|
||||||
|
assert.strictEqual(safeInternalPath('data:text/html,<script>'), '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-path and non-string input', () => {
|
||||||
|
assert.strictEqual(safeInternalPath('hosts'), '/'); // no leading slash
|
||||||
|
assert.strictEqual(safeInternalPath(''), '/');
|
||||||
|
assert.strictEqual(safeInternalPath(undefined), '/');
|
||||||
|
assert.strictEqual(safeInternalPath(null), '/');
|
||||||
|
assert.strictEqual(safeInternalPath({}), '/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constrain a post-login redirect target to a same-origin path.
|
||||||
|
*
|
||||||
|
* Rejects anything that could leave the site or execute script:
|
||||||
|
* - absolute URLs ("https://evil.com") -> not a "/" path
|
||||||
|
* - protocol-relative ("//evil.com", "/\\evil.com") -> host takeover
|
||||||
|
* - scheme targets ("javascript:...", "data:...") -> XSS
|
||||||
|
* Anything not a plain "/path" falls back to "/".
|
||||||
|
*
|
||||||
|
* The browser has its own copy of this in public/lib/js/app-base.js; keep the
|
||||||
|
* two in sync.
|
||||||
|
*/
|
||||||
|
function safeInternalPath(path){
|
||||||
|
if(typeof path !== 'string' || path.charAt(0) !== '/'
|
||||||
|
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {safeInternalPath};
|
||||||
Reference in New Issue
Block a user