cff816fa06
- 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>
112 lines
3.2 KiB
JavaScript
Executable File
112 lines
3.2 KiB
JavaScript
Executable File
'use strict';
|
|
|
|
const router = require('express').Router();
|
|
const { rateLimit } = require('express-rate-limit');
|
|
const conf = require('@simpleworkjs/conf');
|
|
const { Auth } = require('../models/auth');
|
|
const { OidcState } = require('../models/oidc_state');
|
|
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', authLimiter, async function(req, res, next){
|
|
try{
|
|
let auth = await Auth.login(req.body);
|
|
return res.json({
|
|
login: true,
|
|
token: auth.token.token,
|
|
message:`${req.body.username} logged in!`,
|
|
});
|
|
}catch(error){
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
router.all('/logout', async function(req, res, next){
|
|
try{
|
|
if(req.user){
|
|
await req.user.logout();
|
|
}
|
|
|
|
res.json({message: 'Bye'})
|
|
}catch(error){
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* OIDC login start: create a PKCE + state challenge, persist it (auto-expiring
|
|
* via OidcState TTL), and redirect the browser to the SSO authorize endpoint.
|
|
*/
|
|
router.get('/oidc/start', authLimiter, async function(req, res, next){
|
|
try{
|
|
if(!conf.oidc || !conf.oidc.enabled){
|
|
let error = new Error('OidcDisabled');
|
|
error.status = 404;
|
|
error.message = 'OIDC login is not enabled.';
|
|
throw error;
|
|
}
|
|
|
|
let {state, codeVerifier, codeChallenge} = oidc.createAuthRequest();
|
|
await OidcState.create({
|
|
state,
|
|
codeVerifier,
|
|
// 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));
|
|
}catch(error){
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* OIDC callback: validate state (consuming the one-time record), exchange the
|
|
* code for tokens, read identity from userinfo, establish a session, and hand
|
|
* the app token back to the browser via a URL fragment for the login page to
|
|
* store in localStorage.
|
|
*/
|
|
router.get('/oidc/callback', authLimiter, async function(req, res, next){
|
|
try{
|
|
let {code, state} = req.query;
|
|
if(!code || !state){
|
|
let error = new Error('OidcCallbackInvalid');
|
|
error.status = 400;
|
|
error.message = 'Missing code or state.';
|
|
throw error;
|
|
}
|
|
|
|
// get() throws if the state is unknown or has expired — this both binds
|
|
// the callback to our request and bounds replay.
|
|
let saved = await OidcState.get(state);
|
|
await saved.remove();
|
|
|
|
let tokens = await oidc.exchangeCode(code, saved.codeVerifier);
|
|
let claims = await oidc.fetchUserInfo(tokens.access_token);
|
|
let identity = oidc.claimsToIdentity(claims);
|
|
|
|
let {token} = await Auth.oidcSession(identity);
|
|
|
|
let redirect = safeInternalPath(saved.redirect || '/');
|
|
return res.redirect(
|
|
`/login#token=${encodeURIComponent(token.token)}&redirect=${encodeURIComponent(redirect)}`
|
|
);
|
|
}catch(error){
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|