diff --git a/CLAUDE.md b/CLAUDE.md index 9d16a34fc..c9346bfe1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,7 @@ This is the **Portkey AI Gateway** - a fast, reliable AI gateway that routes req - `hooks` - Pre/post request hooks - `memoryCache` - Response caching - `logger` - Request/response logging +- `adminAuth` - Node-only local UI session auth for `/public/*` login endpoints and `/log/stream` - `portkey` - Core Portkey-specific middleware for routing, guardrails, etc. **Plugin System (`plugins/`)** @@ -89,4 +90,6 @@ Tests are organized by component: The gateway uses `conf.json` for runtime configuration. Sample config available in `conf_sample.json`. +For local UI hardening, set `admin_token` in `conf.json`. The `/public` UI uses this token to establish an in-memory admin session, and `/log/stream` requires that session. + Key environment variables and configuration handled through Hono's adapter system for multi-environment deployment. \ No newline at end of file diff --git a/conf.example.json b/conf.example.json index e4c72f33a..865c8113a 100644 --- a/conf.example.json +++ b/conf.example.json @@ -1,4 +1,5 @@ { + "admin_token": "set-a-strong-local-admin-token", "plugins_enabled": [ "default", "portkey", diff --git a/src/middlewares/adminAuth/index.ts b/src/middlewares/adminAuth/index.ts new file mode 100644 index 000000000..9c9da9592 --- /dev/null +++ b/src/middlewares/adminAuth/index.ts @@ -0,0 +1,161 @@ +import { Context, Next } from 'hono'; +import conf from '../../../conf.json'; + +const SESSION_COOKIE_NAME = 'portkey_admin_session'; +const SESSION_MAX_AGE_SECONDS = 60 * 60 * 12; // 12 hours +const adminSessions = new Map(); + +const getConfiguredAdminToken = (): string => { + const adminToken = (conf as Record)?.admin_token; + if ( + !adminToken || + typeof adminToken !== 'string' || + adminToken.trim() === '' + ) { + throw new Error( + 'Admin UI auth requires conf.json.admin_token. Set admin_token or start the gateway with --headless.' + ); + } + return adminToken; +}; + +const parseCookies = (cookieHeader?: string): Record => { + if (!cookieHeader) return {}; + + return cookieHeader.split(';').reduce( + (acc, cookiePart) => { + const [rawKey, ...valueParts] = cookiePart.trim().split('='); + if (!rawKey) return acc; + acc[rawKey] = decodeURIComponent(valueParts.join('=')); + return acc; + }, + {} as Record + ); +}; + +const getSessionId = (c: Context): string | undefined => { + const cookies = parseCookies(c.req.header('cookie')); + return cookies[SESSION_COOKIE_NAME]; +}; + +const getBearerToken = (c: Context): string | undefined => { + const authHeader = c.req.header('authorization'); + if (!authHeader) return undefined; + + const [scheme, token] = authHeader.trim().split(/\s+/, 2); + if (!scheme || !token) return undefined; + if (scheme.toLowerCase() !== 'bearer') return undefined; + + return token; +}; + +const isSessionActive = (sessionId?: string): boolean => { + if (!sessionId) return false; + + const expiresAt = adminSessions.get(sessionId); + if (!expiresAt) return false; + + if (expiresAt < Date.now()) { + adminSessions.delete(sessionId); + return false; + } + + return true; +}; + +const createSession = (): string => { + const sessionId = crypto.randomUUID(); + const expiresAt = Date.now() + SESSION_MAX_AGE_SECONDS * 1000; + adminSessions.set(sessionId, expiresAt); + return sessionId; +}; + +const setSessionCookie = (c: Context, sessionId: string) => { + c.header( + 'Set-Cookie', + `${SESSION_COOKIE_NAME}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_MAX_AGE_SECONDS}` + ); +}; + +export const adminAuthMiddleware = async (c: Context, next: Next) => { + let configuredToken: string; + try { + configuredToken = getConfiguredAdminToken(); + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Admin UI auth is misconfigured.'; + return c.json({ status: 'failure', message }, 500); + } + + const hasValidSession = isSessionActive(getSessionId(c)); + const hasValidBearerToken = getBearerToken(c) === configuredToken; + + if (!hasValidSession && !hasValidBearerToken) { + return c.json( + { + status: 'failure', + message: + 'Admin authentication required. Use a valid admin session cookie or Authorization: Bearer .', + }, + 401 + ); + } + + await next(); +}; + +export const adminAuthSessionStatusHandler = (c: Context) => { + try { + getConfiguredAdminToken(); + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Admin UI auth is misconfigured.'; + return c.json({ status: 'failure', message }, 500); + } + + return c.json({ authenticated: isSessionActive(getSessionId(c)) }); +}; + +export const adminAuthLoginHandler = async (c: Context) => { + let configuredToken: string; + try { + configuredToken = getConfiguredAdminToken(); + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Admin UI auth is misconfigured.'; + return c.json({ status: 'failure', message }, 500); + } + + let body: { admin_token?: string } = {}; + try { + body = await c.req.json(); + } catch { + return c.json( + { + status: 'failure', + message: 'Invalid request body. Expected JSON with admin_token.', + }, + 400 + ); + } + + if (!body.admin_token || body.admin_token !== configuredToken) { + return c.json( + { + status: 'failure', + message: 'Invalid admin token.', + }, + 401 + ); + } + + const sessionId = createSession(); + setSessionCookie(c, sessionId); + return c.json({ authenticated: true }); +}; diff --git a/src/middlewares/log/index.ts b/src/middlewares/log/index.ts index 5d5319e44..474334832 100644 --- a/src/middlewares/log/index.ts +++ b/src/middlewares/log/index.ts @@ -15,6 +15,68 @@ const removeLogClient = (clientId: any) => { logClients.delete(clientId); }; +const sanitizeHeaders = (headers: Record = {}) => + Object.fromEntries(Object.keys(headers).map((key) => [key, '[REDACTED]'])); + +const ALLOWED_PROVIDER_OPTION_KEYS = new Set([ + 'provider', + 'overrideParams', + 'retry', + 'cache', + 'requestURL', + 'rubeusURL', +]); + +const sanitizeProviderOptions = ( + providerOptions: Record = {} +) => + Object.fromEntries( + Object.entries(providerOptions).map(([key, value]) => [ + key, + ALLOWED_PROVIDER_OPTION_KEYS.has(key) ? value : '[REDACTED]', + ]) + ); + +const sanitizeRequestOption = (requestOption: any) => { + if (!requestOption || typeof requestOption !== 'object') return requestOption; + + const sanitizedOption = { ...requestOption }; + + if ( + sanitizedOption.providerOptions && + typeof sanitizedOption.providerOptions === 'object' + ) { + sanitizedOption.providerOptions = sanitizeProviderOptions( + sanitizedOption.providerOptions as Record + ); + } + + if ( + sanitizedOption.transformedRequest && + typeof sanitizedOption.transformedRequest === 'object' + ) { + sanitizedOption.transformedRequest = { + ...sanitizedOption.transformedRequest, + }; + if (sanitizedOption.transformedRequest.headers) { + sanitizedOption.transformedRequest.headers = sanitizeHeaders( + sanitizedOption.transformedRequest.headers as Record + ); + } + } + + if ( + sanitizedOption.responseHeaders && + typeof sanitizedOption.responseHeaders === 'object' + ) { + sanitizedOption.responseHeaders = sanitizeHeaders( + sanitizedOption.responseHeaders as Record + ); + } + + return sanitizedOption; +}; + const broadcastLog = async (log: any) => { const message = { data: log, @@ -79,7 +141,7 @@ async function processLog(c: Context, start: number) { endpoint: c.req.url.split(':8787')[1], status: c.res.status, duration: ms, - requestOptions: requestOptionsArray, + requestOptions: requestOptionsArray.map(sanitizeRequestOption), }) ); } diff --git a/src/public/index.html b/src/public/index.html index 9bd7e77e2..014ff42b1 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -851,6 +851,54 @@ border-bottom-color: #3b82f6; font-weight: bold; } + + body.admin-auth-pending header, + body.admin-auth-pending .main-content { + display: none; + } + + .admin-auth-modal { + position: fixed; + inset: 0; + z-index: 3000; + display: none; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.6); + padding: 1rem; + } + + body.admin-auth-pending .admin-auth-modal { + display: flex; + } + + .admin-auth-card { + width: 100%; + max-width: 420px; + background: white; + border-radius: 0.75rem; + padding: 1.25rem; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25); + } + + .admin-auth-title { + margin: 0 0 0.4rem; + font-size: 1.2rem; + font-weight: 700; + } + + .admin-auth-subtitle { + margin: 0 0 0.9rem; + font-size: 0.875rem; + color: #6b7280; + } + + .admin-auth-error { + margin-top: 0.6rem; + color: #dc2626; + font-size: 0.8rem; + min-height: 1.1rem; + } @@ -867,11 +915,29 @@ - + +
+
+

Admin token required

+

Enter your admin_token stored in conf.json to access the local gateway UI. see github discussion for more details.

+
+ + +
+
+
+