harden authentication and authorization on the user API

This commit is contained in:
Jarek Krochmalski committed 2026-08-27 11:31:35 +02:00
1 parent 5adb374c0d
commit 0816672222
5 files changed
+185 -30

No files matched your search

+11 -6
View File
@@ -1,6 +1,6 @@
// v1.0.12
import '$lib/server/dns-dispatcher.js';
import { initDatabase, hasAdminUser } from '$lib/server/db';
import { initDatabase, hasAnyUser } from '$lib/server/db';
import { startSubprocesses, stopSubprocesses } from '$lib/server/subprocess-manager';
import { startScheduler } from '$lib/server/scheduler';
import { isAuthEnabled, validateSession } from '$lib/server/auth';
@@ -281,8 +281,11 @@ function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some(path => pathname === path || pathname.startsWith(path + '/'));
}
// Check if path is a static asset
// True only for real static files. API routes are never static, even when a path
// ends in an asset-like extension (a route parameter can), so they always go through
// the normal request handling below.
function isStaticAsset(pathname: string): boolean {
if (pathname.startsWith('/api/')) return false;
return pathname.startsWith('/_app/') ||
pathname.startsWith('/favicon') ||
pathname.endsWith('.webp') ||
@@ -377,10 +380,12 @@ export const handle: Handle = async ({ event, resolve }) => {
// If not authenticated
if (!user) {
// Special case: allow user creation when auth is enabled but no admin exists yet
// This enables the first admin user to be created during initial setup
const noAdminSetupMode = !(await hasAdminUser());
if (noAdminSetupMode && event.url.pathname === '/api/users' && event.request.method === 'POST') {
// Initial setup only: allow creating the very first account when NO user exists
// yet (there is no one to authenticate as). Once any account exists, creation
// goes through the normal authenticated path. Bounding this on any-user (not the
// Admin role) keeps it from reopening when accounts exist but no admin does.
const firstRunSetup = !(await hasAnyUser());
if (firstRunSetup && event.url.pathname === '/api/users' && event.request.method === 'POST') {
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
}
+129 -14
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "Dockhand API",
"version": "1.0.44",
"version": "1.0.45",
"description": "Auto-generated from src/routes/**/+server.ts by scripts/generate-openapi.ts. Path, HTTP method, tag, and auth requirement are derived automatically from the route tree and hooks.server.ts PUBLIC_PATHS. Parameters, response status codes, and request body fields are additionally auto-detected from each handler’s own source (query params via url.searchParams, status codes via status:/error(), body fields via destructuring) — adding a new endpoint therefore requires ZERO manual spec edits to show up with real params/responses. An optional, additive `@openapi` JSDoc annotation on a handler replaces the generic auto-descriptions with hand-written summaries, exact types, and examples."
},
"servers": [
@@ -2537,6 +2537,7 @@
}
},
"security": [],
"description": "sessionTimeout is in seconds (clamped 3600..604800); 0 disables expiry so sessions never time out.",
"requestBody": {
"required": true,
"content": {
@@ -2549,12 +2550,16 @@
},
"defaultProvider": {
"type": "string"
},
"sessionTimeout": {
"type": "integer"
}
}
},
"example": {
"authEnabled": true,
"defaultProvider": "local"
"defaultProvider": "local",
"sessionTimeout": 86400
}
}
}
@@ -10170,7 +10175,7 @@
"parameters": [],
"responses": {
"200": {
"description": "Environments accessible to the caller (filtered by RBAC in Enterprise mode; all environments in Free edition)",
"description": "Environments accessible to the caller (filtered by RBAC in Enterprise mode; all environments in Free edition). The tlsKey (private TLS client key) and hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored.",
"content": {
"application/json": {
"schema": {
@@ -10204,6 +10209,12 @@
},
"timezone": {
"type": "string"
},
"hasTlsKey": {
"type": "boolean"
},
"hasHawserToken": {
"type": "boolean"
}
},
"required": [
@@ -10223,7 +10234,9 @@
"protocol": "http",
"icon": "server",
"publicIp": "203.0.113.10",
"timezone": "Europe/Berlin"
"timezone": "Europe/Berlin",
"hasTlsKey": false,
"hasHawserToken": false
},
{
"id": 2,
@@ -10234,7 +10247,9 @@
"protocol": "http",
"icon": "server",
"publicIp": null,
"timezone": "Europe/Berlin"
"timezone": "Europe/Berlin",
"hasTlsKey": false,
"hasHawserToken": true
}
]
}
@@ -10386,7 +10401,7 @@
],
"responses": {
"200": {
"description": "Successful response",
"description": "The tlsKey (private TLS client key) and hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored.",
"content": {
"application/json": {
"schema": {
@@ -10409,6 +10424,12 @@
},
"publicIp": {
"type": "string"
},
"hasTlsKey": {
"type": "boolean"
},
"hasHawserToken": {
"type": "boolean"
}
},
"required": [
@@ -10424,7 +10445,9 @@
"labels": [
"string"
],
"publicIp": "string"
"publicIp": "string",
"hasTlsKey": true,
"hasHawserToken": true
}
}
}
@@ -10467,7 +10490,7 @@
],
"responses": {
"200": {
"description": "Successful response",
"description": "The tlsKey/hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored.",
"content": {
"application/json": {
"schema": {
@@ -10490,6 +10513,12 @@
},
"publicIp": {
"type": "string"
},
"hasTlsKey": {
"type": "boolean"
},
"hasHawserToken": {
"type": "boolean"
}
},
"required": [
@@ -10505,7 +10534,9 @@
"labels": [
"string"
],
"publicIp": "string"
"publicIp": "string",
"hasTlsKey": true,
"hasHawserToken": true
}
}
}
@@ -15498,13 +15529,10 @@
],
"responses": {
"200": {
"description": "Raw image/svg+xml body, Cache-Control public max-age=604800"
"description": "Raw image/svg+xml body (the icon, or a neutral placeholder if it can't be resolved), Cache-Control public max-age=604800"
},
"400": {
"description": "The ref is not a valid selfh.st reference"
},
"404": {
"description": "No such icon (invalid ref or upstream fetch failed)"
}
},
"security": [
@@ -15515,7 +15543,82 @@
"bearerAuth": []
}
],
"description": "Proxies an icon from the selfh.st collection so the browser never contacts an external CDN directly. The first request for a given ref fetches the SVG from jsdelivr and caches it under DATA_DIR; later requests are served locally. Icons are CC BY 4.0 (selfh.st). Returns 404 if the ref is invalid or the icon can't be fetched, so the UI falls back to a generic icon."
"description": "Proxies an icon from the selfh.st collection so the browser never contacts an external CDN directly. The first request for a given ref fetches the SVG from jsdelivr and caches it under DATA_DIR; later requests are served locally. Icons are CC BY 4.0 (selfh.st). When the icon can't be resolved it returns a neutral placeholder SVG (200) rather than a 404, so a grid of icons doesn't read as probing to a WAF."
}
},
"/api/icons/selfhst/batch": {
"post": {
"operationId": "post_api_icons_selfhst_batch",
"tags": [
"icons"
],
"summary": "Resolve multiple selfh.st app icons in one request (avoids per-icon requests a WAF flags as crawling)",
"parameters": [],
"responses": {
"200": {
"description": "icons maps each resolved reference to a data:image/svg+xml base64 URI; unresolvable refs are omitted.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"icons": {
"type": "string"
}
},
"required": [
"icons"
]
},
"example": {
"icons": {
"plex": "data:image/svg+xml;base64,PHN2Zy4uLg=="
}
}
}
}
},
"400": {
"description": "The request body is missing a refs array"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"description": "Takes a list of selfh.st icon references and returns each resolved icon as a data:image/svg+xml URI, keyed by reference. Unresolvable or invalid refs are omitted rather than returned as errors. Each icon is fetched from the CDN once and cached on disk, same as the single-icon endpoint.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"refs": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"refs"
]
},
"example": {
"refs": [
"plex",
"gitea",
"grafana"
]
}
}
}
}
}
},
"/api/icons/selfhst-manifest": {
@@ -27088,6 +27191,9 @@
},
"projectUrl": {
"type": "string"
},
"detailsUrl": {
"type": "string"
}
},
"required": [
@@ -27645,6 +27751,9 @@
"400": {
"description": "Missing username/password, or password shorter than 8 characters"
},
"401": {
"description": "Authentication required (auth is enabled and the caller is not authenticated; the first admin during initial setup is exempt)"
},
"403": {
"description": "Permission denied (RBAC 'users:create' missing — only applies once an admin already exists)"
},
@@ -27887,6 +27996,9 @@
"400": {
"description": "User id is required, or the new password is shorter than 8 characters"
},
"401": {
"description": "Authentication required (auth is enabled and the caller is not authenticated)"
},
"403": {
"description": "Permission denied (editing another user without users:edit)"
},
@@ -28001,6 +28113,9 @@
"400": {
"description": "User id is required"
},
"401": {
"description": "Authentication required (auth is enabled and the caller is not authenticated)"
},
"403": {
"description": "Permission denied (missing users:remove)"
},
+15 -2
View File
@@ -1358,9 +1358,11 @@ export async function updateAuthSettings(data: Partial<AuthSettingsData>): Promi
if (data.authEnabled !== undefined) updateData.authEnabled = data.authEnabled;
if (data.defaultProvider !== undefined) updateData.defaultProvider = data.defaultProvider;
if (data.sessionTimeout !== undefined) {
// Cap session timeout to safe maximum (30 days)
// 0 is the "never expire" sentinel (#1302); otherwise clamp 1s..30 days.
const MAX_SESSION_TIMEOUT = 2592000; // 30 days in seconds
updateData.sessionTimeout = Math.min(Math.max(1, data.sessionTimeout), MAX_SESSION_TIMEOUT);
updateData.sessionTimeout = data.sessionTimeout === 0
? 0
: Math.min(Math.max(1, data.sessionTimeout), MAX_SESSION_TIMEOUT);
}
// Get existing row's id (may not be 1 after db reset/migration)
@@ -1443,6 +1445,17 @@ export async function hasAdminUser(): Promise<boolean> {
return result.length > 0;
}
/**
* Whether any user account exists at all. Used to bound the "create the first account
* with no auth" allowance to a genuine first run - once ANY user exists, account
* creation goes through the normal authenticated path. (hasAdminUser only tracks the
* Admin role, which can be absent while user rows still exist.)
*/
export async function hasAnyUser(): Promise<boolean> {
const result = await db.select({ id: users.id }).from(users).limit(1);
return result.length > 0;
}
export async function countAdminUsers(): Promise<number> {
// Import license check dynamically to avoid circular dependencies
const { isEnterprise } = await import('./license');
+9 -4
View File
@@ -82,6 +82,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
* resp-201-desc: The first user ever created (or every user in Free edition) automatically gets the Admin role
* resp-201-example: {"id":1,"username":"jdoe","email":"jdoe@example.com","isAdmin":true}
* resp-400: Missing username/password, or password shorter than 8 characters
* resp-401: Authentication required (auth is enabled and the caller is not authenticated; the first admin during initial setup is exempt)
* resp-403: Permission denied (RBAC 'users:create' missing — only applies once an admin already exists)
* resp-409: Username already exists
* resp-500: Unexpected error while creating the user
@@ -90,9 +91,13 @@ export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
// When auth is enabled and user is logged in, check they can manage users
// (allow if no user logged in for initial setup when no users exist)
if (auth.authEnabled && auth.isAuthenticated && !await auth.canManageUsers()) {
// When auth is enabled, a caller must be authenticated and allowed to manage users.
// Initial setup runs with auth disabled (the first admin is created then), so that
// path is unaffected.
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (auth.authEnabled && !await auth.canManageUsers()) {
return json({ error: 'Permission denied' }, { status: 403 });
}
@@ -111,7 +116,7 @@ export const POST: RequestHandler = async (event) => {
// Hash password
const passwordHash = await hashPassword(password);
// Check if this is the first user
// The first user (no Admin-role user yet) is auto-assigned the Admin role below.
const isFirstUser = !(await hasAdminUser());
// Create user
+21 -4
View File
@@ -81,6 +81,7 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
* body-example: {"displayName":"Jane Doe","email":"jane@example.com"}
* resp-200: {id:integer!, username:string!, email:string, displayName:string, mfaEnabled:boolean!, isAdmin:boolean!, isActive:boolean!, lastLogin:string, createdAt:string!, updatedAt:string!}
* resp-400: User id is required, or the new password is shorter than 8 characters
* resp-401: Authentication required (auth is enabled and the caller is not authenticated)
* resp-403: Permission denied (editing another user without users:edit)
* resp-404: User not found
* resp-409: This is the last admin user (confirmDisableAuth required), or the username already exists
@@ -96,10 +97,20 @@ export const PUT: RequestHandler = async (event) => {
const userId = parseInt(params.id);
// Allow users to edit their own profile, otherwise check permission
// (free edition allows all, enterprise checks RBAC)
if (auth.user && auth.user.id !== userId && !await auth.can('users', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
// Require an authenticated caller when auth is enabled.
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
// With auth enabled: allow a user to edit their own profile, otherwise require the
// users-edit permission. Written fail-closed - editing another account is denied
// unless there is a caller who either is that user or holds the permission - so it
// does not depend on the 401 above having run first.
if (auth.authEnabled) {
const isSelfEdit = auth.user?.id === userId;
if (!isSelfEdit && !await auth.can('users', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
}
try {
@@ -283,6 +294,7 @@ export const PUT: RequestHandler = async (event) => {
* query: confirmDisableAuth:boolean Set to true to confirm deleting the last admin (which disables authentication)
* resp-200: {success:boolean!, authDisabled:boolean}
* resp-400: User id is required
* resp-401: Authentication required (auth is enabled and the caller is not authenticated)
* resp-403: Permission denied (missing users:remove)
* resp-404: User not found
* resp-409: This is the last admin user — pass confirmDisableAuth=true to proceed
@@ -292,6 +304,11 @@ export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
// Require an authenticated caller when auth is enabled, before the permission check.
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
// When auth is enabled, check permission (free edition allows all, enterprise checks RBAC)
if (auth.authEnabled && auth.isAuthenticated && !await auth.can('users', 'remove')) {
return json({ error: 'Permission denied' }, { status: 403 });