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:
Zakaria
2026-10-23 10:43:41 -04:00
parent c9b0677fed
commit c5a2fc9105
5 changed files with 110 additions and 2 deletions
+13 -1
View File
@@ -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) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'}[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) }