fix(api): block open redirects, add health check and body limits, run as non-root
- safeReturnTo resolves the target against a placeholder origin and rejects backslashes and control characters, so `/\evil.example` and similar payloads can no longer redirect off-site after sign-in. - Add a real unauthenticated /api/health and answer unknown /api/* paths with a JSON 404 instead of the SPA shell. - Limit request bodies: 16 KB for /api/auth/* (read before a session exists), 128 MB for resource create/update and uploads, 10 MB elsewhere. - Run the production image as an unprivileged user and add a Docker HEALTHCHECK against /api/health.
This commit is contained in:
@@ -290,7 +290,19 @@ function loginErrorMessage(code: string | undefined): string | null {
|
||||
|
||||
function isPublic(path: string): boolean { return path === '/login' || path === '/login.js' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' }
|
||||
function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) }
|
||||
function safeReturnTo(value?: string): string { return value && value.startsWith('/') && !value.startsWith('//') ? value : '/console/aws' }
|
||||
// Browsers treat `\` like `/` and drop tabs/newlines in URLs, so `/\evil.example` would
|
||||
// otherwise pass a plain prefix check and land on another origin.
|
||||
function safeReturnTo(value?: string): string {
|
||||
const fallback = '/console/aws'
|
||||
if (!value || !value.startsWith('/') || /[\\\u0000-\u001f\u007f]/.test(value)) return fallback
|
||||
const base = 'http://return-to.invalid'
|
||||
try {
|
||||
const url = new URL(value, base)
|
||||
return url.origin === base ? `${url.pathname}${url.search}${url.hash}` : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character)) }
|
||||
function acceptsHtml(c: Context): boolean { return c.req.header('accept')?.includes('text/html') ?? false }
|
||||
function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(method) }
|
||||
|
||||
Reference in New Issue
Block a user