mirror of
https://gh-proxy.org/https://github.com/klinsmaya/gateway.git
synced 2026-11-08 00:57:59 +08:00
Merge pull request #1657 from Portkey-AI/fix/add-auth-validation-for-public-routes
add auth validation for public routes
This commit is contained in:
@@ -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.
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"admin_token": "set-a-strong-local-admin-token",
|
||||
"plugins_enabled": [
|
||||
"default",
|
||||
"portkey",
|
||||
|
||||
@@ -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<string, number>();
|
||||
|
||||
const getConfiguredAdminToken = (): string => {
|
||||
const adminToken = (conf as Record<string, unknown>)?.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<string, string> => {
|
||||
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<string, string>
|
||||
);
|
||||
};
|
||||
|
||||
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 <admin_token>.',
|
||||
},
|
||||
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 });
|
||||
};
|
||||
@@ -15,6 +15,68 @@ const removeLogClient = (clientId: any) => {
|
||||
logClients.delete(clientId);
|
||||
};
|
||||
|
||||
const sanitizeHeaders = (headers: Record<string, unknown> = {}) =>
|
||||
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<string, unknown> = {}
|
||||
) =>
|
||||
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<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
sanitizedOption.transformedRequest &&
|
||||
typeof sanitizedOption.transformedRequest === 'object'
|
||||
) {
|
||||
sanitizedOption.transformedRequest = {
|
||||
...sanitizedOption.transformedRequest,
|
||||
};
|
||||
if (sanitizedOption.transformedRequest.headers) {
|
||||
sanitizedOption.transformedRequest.headers = sanitizeHeaders(
|
||||
sanitizedOption.transformedRequest.headers as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
sanitizedOption.responseHeaders &&
|
||||
typeof sanitizedOption.responseHeaders === 'object'
|
||||
) {
|
||||
sanitizedOption.responseHeaders = sanitizeHeaders(
|
||||
sanitizedOption.responseHeaders as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
+184
-4
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
@@ -867,11 +915,29 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/confetti.browser.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body class="admin-auth-pending">
|
||||
<!-- Google Tag Manager (noscript) -->
|
||||
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-KX3PMNVR"
|
||||
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
|
||||
<!-- End Google Tag Manager (noscript) -->
|
||||
<div class="admin-auth-modal" id="adminAuthModal">
|
||||
<div class="admin-auth-card">
|
||||
<h2 class="admin-auth-title">Admin token required</h2>
|
||||
<p class="admin-auth-subtitle">Enter your <code>admin_token</code> stored in <code>conf.json</code> to access the local gateway UI. see <a href="https://github.com/Portkey-AI/gateway/discussions/1656">github discussion</a> for more details.</p>
|
||||
<form id="adminAuthForm">
|
||||
<input
|
||||
id="adminAuthTokenInput"
|
||||
class="input"
|
||||
type="password"
|
||||
placeholder="Enter admin token"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
<button type="submit" class="btn">Unlock UI</button>
|
||||
</form>
|
||||
<div class="admin-auth-error" id="adminAuthError"></div>
|
||||
</div>
|
||||
</div>
|
||||
<header>
|
||||
<div class="container">
|
||||
<div class="logo">
|
||||
@@ -1624,6 +1690,88 @@
|
||||
const saveApiDetailsBtn = document.getElementById('saveApiDetailsBtn');
|
||||
const languageSelect = document.getElementById('languageSelect');
|
||||
const copyConfigBtn = document.getElementById('copyConfigBtn');
|
||||
const adminAuthModal = document.getElementById('adminAuthModal');
|
||||
const adminAuthForm = document.getElementById('adminAuthForm');
|
||||
const adminAuthTokenInput = document.getElementById('adminAuthTokenInput');
|
||||
const adminAuthError = document.getElementById('adminAuthError');
|
||||
|
||||
let isAdminAuthenticated = false;
|
||||
let hasStartedLogSource = false;
|
||||
|
||||
function setAdminAuthError(message = '') {
|
||||
adminAuthError.textContent = message;
|
||||
}
|
||||
|
||||
function lockUi() {
|
||||
document.body.classList.add('admin-auth-pending');
|
||||
}
|
||||
|
||||
function unlockUi() {
|
||||
document.body.classList.remove('admin-auth-pending');
|
||||
}
|
||||
|
||||
async function checkAdminSession() {
|
||||
try {
|
||||
const response = await fetch('/public/auth/session', {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json();
|
||||
setAdminAuthError(payload.message || 'Unable to validate admin session.');
|
||||
lockUi();
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return Boolean(payload.authenticated);
|
||||
} catch (error) {
|
||||
setAdminAuthError('Unable to connect to gateway auth endpoint.');
|
||||
lockUi();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unlockUiWithToken(adminToken) {
|
||||
try {
|
||||
const response = await fetch('/public/auth', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ admin_token: adminToken }),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setAdminAuthError(payload.message || 'Invalid admin token.');
|
||||
return false;
|
||||
}
|
||||
|
||||
setAdminAuthError('');
|
||||
adminAuthTokenInput.value = '';
|
||||
return true;
|
||||
} catch (error) {
|
||||
setAdminAuthError('Authentication request failed. Please try again.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAdminAuth() {
|
||||
const isAuthenticated = await checkAdminSession();
|
||||
if (isAuthenticated) {
|
||||
isAdminAuthenticated = true;
|
||||
unlockUi();
|
||||
if (!hasStartedLogSource) {
|
||||
setupLogSource();
|
||||
hasStartedLogSource = true;
|
||||
}
|
||||
} else {
|
||||
isAdminAuthenticated = false;
|
||||
lockUi();
|
||||
}
|
||||
}
|
||||
|
||||
const camelToSnakeCase = str => str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||
const camelToKebabCase = str => str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
|
||||
@@ -1800,6 +1948,28 @@
|
||||
|
||||
// Event Listeners
|
||||
testRequestBtn.addEventListener('click', dummyTestRequest);
|
||||
adminAuthForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
setAdminAuthError('');
|
||||
|
||||
const token = adminAuthTokenInput.value.trim();
|
||||
if (!token) {
|
||||
setAdminAuthError('Admin token is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const unlocked = await unlockUiWithToken(token);
|
||||
if (!unlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
isAdminAuthenticated = true;
|
||||
unlockUi();
|
||||
if (!hasStartedLogSource) {
|
||||
setupLogSource();
|
||||
hasStartedLogSource = true;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.tabs').forEach(tabsContainer => {
|
||||
tabsContainer.querySelectorAll('.tab').forEach(tab => {
|
||||
@@ -1922,6 +2092,7 @@
|
||||
});
|
||||
|
||||
managePage()
|
||||
ensureAdminAuth();
|
||||
|
||||
// Logs functionality
|
||||
const logsTableBody = document.getElementById('logsTableBody');
|
||||
@@ -1934,6 +2105,10 @@
|
||||
let logSource;
|
||||
|
||||
function setupLogSource() {
|
||||
if (!isAdminAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
logSource = new EventSource('/log/stream');
|
||||
|
||||
logSource.addEventListener('connected', (event) => {
|
||||
@@ -1965,18 +2140,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
function reconnectLogSource() {
|
||||
async function reconnectLogSource() {
|
||||
if (logSource) {
|
||||
logSource.close();
|
||||
}
|
||||
const stillAuthenticated = await checkAdminSession();
|
||||
if (!stillAuthenticated) {
|
||||
isAdminAuthenticated = false;
|
||||
lockUi();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Attempting to reconnect to log stream...');
|
||||
setTimeout(() => {
|
||||
setupLogSource();
|
||||
}, 5000); // Wait 5 seconds before attempting to reconnect
|
||||
}
|
||||
|
||||
setupLogSource();
|
||||
|
||||
function addLogEntry(time, method, endpoint, status, duration, requestOptions) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.classList.add('new-row');
|
||||
|
||||
+18
-1
@@ -8,6 +8,12 @@ import { Context } from 'hono';
|
||||
import { createNodeWebSocket } from '@hono/node-ws';
|
||||
import { realTimeHandlerNode } from './handlers/realtimeHandlerNode';
|
||||
import { requestValidator } from './middlewares/requestValidator';
|
||||
import conf from '../conf.json';
|
||||
import {
|
||||
adminAuthLoginHandler,
|
||||
adminAuthMiddleware,
|
||||
adminAuthSessionStatusHandler,
|
||||
} from './middlewares/adminAuth';
|
||||
|
||||
// Extract the port number from the command line arguments
|
||||
const defaultPort = 8787;
|
||||
@@ -16,6 +22,10 @@ const portArg = args.find((arg) => arg.startsWith('--port='));
|
||||
const port = portArg ? parseInt(portArg.split('=')[1]) : defaultPort;
|
||||
|
||||
const isHeadless = args.includes('--headless');
|
||||
const hasAdminTokenKey = Object.prototype.hasOwnProperty.call(
|
||||
conf || {},
|
||||
'admin_token'
|
||||
);
|
||||
|
||||
// Setup static file serving only if not in headless mode
|
||||
if (
|
||||
@@ -37,10 +47,17 @@ if (
|
||||
const indexContent = readFileSync(indexPath, 'utf-8');
|
||||
|
||||
const serveIndex = (c: Context) => {
|
||||
if (!hasAdminTokenKey) {
|
||||
return c.html(
|
||||
'Admin token is required to access the local gateway UI. Please set admin_token in conf.json. see <a href="https://github.com/Portkey-AI/gateway/discussions/1656">github discussion</a> for more details.'
|
||||
);
|
||||
}
|
||||
return c.html(indexContent);
|
||||
};
|
||||
|
||||
// Set up routes
|
||||
app.post('/public/auth', adminAuthLoginHandler);
|
||||
app.get('/public/auth/session', adminAuthSessionStatusHandler);
|
||||
app.get('/public/logs', serveIndex);
|
||||
app.get('/public/', serveIndex);
|
||||
|
||||
@@ -69,7 +86,7 @@ if (
|
||||
return Promise.race([fn(), timeoutPromise]);
|
||||
}
|
||||
|
||||
app.get('/log/stream', (c: Context) => {
|
||||
app.get('/log/stream', adminAuthMiddleware, (c: Context) => {
|
||||
const clientId = Date.now().toString();
|
||||
|
||||
// Set headers to prevent caching
|
||||
|
||||
Reference in New Issue
Block a user