mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-13 16:00:02 +02:00
add virtual api key validation in verifySession
This commit is contained in:
@@ -39,6 +39,10 @@ import {
|
||||
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
|
||||
import { isIpInCidr } from "@server/lib/ip";
|
||||
import { localCache } from "@server/lib/cache";
|
||||
import {
|
||||
AI_GATEWAY_TRUST_HEADER,
|
||||
isAiGatewayTrustHeaderValid
|
||||
} from "@server/lib/aiGatewayTrust";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import {
|
||||
@@ -217,9 +221,42 @@ async function buildRequestUser(
|
||||
|
||||
async function resolveRequestUser(
|
||||
req: Request,
|
||||
_resourceId: number | null,
|
||||
resourceId: number | null,
|
||||
orgId: string | null
|
||||
): Promise<RequestUser | null> {
|
||||
// Public inference: identity comes from Badger via Remote-* only when the
|
||||
// Traefik trust header proves the request passed verify-session (VAK).
|
||||
if (isAiGatewayTrustHeaderValid(req.headers as Record<string, string>)) {
|
||||
const userId = getRequestHeader(req, "remote-user-id");
|
||||
if (userId) {
|
||||
const username = getRequestHeader(req, "remote-user") || userId;
|
||||
const email = getRequestHeader(req, "remote-email");
|
||||
const name = getRequestHeader(req, "remote-name");
|
||||
const role = getRequestHeader(req, "remote-role");
|
||||
const orgRoles = orgId ? await getUserOrgRoles(userId, orgId) : [];
|
||||
|
||||
return {
|
||||
userId,
|
||||
username,
|
||||
email: email || null,
|
||||
name: name || null,
|
||||
role:
|
||||
role || orgRoles.map((r) => r.roleName).join(", ") || null,
|
||||
roleIds: orgRoles.map((r) => r.roleId)
|
||||
};
|
||||
}
|
||||
|
||||
// Trusted request with no associated user (manual key without userId).
|
||||
if (resourceId != null) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Public inference must come through Badger; do not authorize via app session.
|
||||
if (resourceId != null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME];
|
||||
if (sessionToken) {
|
||||
const { session, user } = await validateSessionToken(sessionToken);
|
||||
@@ -251,6 +288,14 @@ async function resolveRequestUser(
|
||||
return buildRequestUser(client.userId, orgId);
|
||||
}
|
||||
|
||||
function getRequestHeader(req: Request, name: string): string | undefined {
|
||||
const raw = req.headers[name.toLowerCase()];
|
||||
if (Array.isArray(raw)) {
|
||||
return raw[0];
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
||||
const [[resourceRow], [siteResourceRow]] = await Promise.all([
|
||||
db
|
||||
@@ -696,6 +741,21 @@ export async function handleAiGatewayProxy(
|
||||
orgId
|
||||
} = target;
|
||||
|
||||
// Public inference must pass Badger verify-session first. Traefik
|
||||
// injects the trust header only on that path; the gateway trusts it
|
||||
// and does not re-verify the virtual API key.
|
||||
if (
|
||||
resourceId != null &&
|
||||
!isAiGatewayTrustHeaderValid(req.headers as Record<string, string>)
|
||||
) {
|
||||
return res.status(HttpCode.UNAUTHORIZED).json({
|
||||
error: {
|
||||
message:
|
||||
"Request must be authenticated via the inference resource"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const capableAttachments = attachments.filter((a) =>
|
||||
providerHasCapability(a.provider.capabilities, capability)
|
||||
);
|
||||
@@ -823,7 +883,8 @@ export async function handleAiGatewayProxy(
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"content-length",
|
||||
"accept-encoding"
|
||||
"accept-encoding",
|
||||
AI_GATEWAY_TRUST_HEADER.toLowerCase()
|
||||
]);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type RequestUser
|
||||
} from "@server/routers/aiGateway/pipeline";
|
||||
import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse";
|
||||
import { AI_GATEWAY_TRUST_HEADER } from "@server/lib/aiGatewayTrust";
|
||||
|
||||
// Short TTL: long enough to spare the DB on a burst of requests, short
|
||||
// enough that target/site changes (added, removed, exit node moved) show up
|
||||
@@ -62,7 +63,8 @@ const SKIP_HEADERS = new Set([
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"content-length",
|
||||
"accept-encoding"
|
||||
"accept-encoding",
|
||||
AI_GATEWAY_TRUST_HEADER.toLowerCase()
|
||||
]);
|
||||
|
||||
type ResolvedProviderTarget = {
|
||||
|
||||
@@ -19,6 +19,7 @@ Reasons:
|
||||
106 - Valid email
|
||||
107 - Valid SSO
|
||||
108 - Connected Client
|
||||
109 - Valid Virtual API Key
|
||||
|
||||
201 - Resource Not Found
|
||||
202 - Resource Blocked
|
||||
@@ -90,7 +91,9 @@ async function flushAuditLogs() {
|
||||
auditLogBuffer.unshift(...logsToWrite);
|
||||
logger.info(`Re-queued ${logsToWrite.length} audit logs for retry`);
|
||||
} else {
|
||||
logger.error(`Buffer full, dropped ${logsToWrite.length} audit logs`);
|
||||
logger.error(
|
||||
`Buffer full, dropped ${logsToWrite.length} audit logs`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isFlushInProgress = false;
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
} from "@server/auth/sessions/resource";
|
||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||
import {
|
||||
extractVirtualApiKeyCredential,
|
||||
verifyVirtualApiKey
|
||||
} from "@server/auth/verifyVirtualApiKey";
|
||||
import {
|
||||
getResourceByDomain,
|
||||
getResourceRules,
|
||||
@@ -127,7 +131,8 @@ export async function verifyResourceSession(
|
||||
// Extract HTTP Basic Auth credentials if present
|
||||
const clientHeaderAuth = extractBasicAuth(headers);
|
||||
|
||||
const clientUserAgent = headers?.["user-agent"] || headers?.["User-Agent"];
|
||||
const clientUserAgent =
|
||||
headers?.["user-agent"] || headers?.["User-Agent"];
|
||||
const clientIsBrowser = isBrowserUserAgent(clientUserAgent);
|
||||
|
||||
const clientIp = requestIp
|
||||
@@ -254,20 +259,28 @@ export async function verifyResourceSession(
|
||||
);
|
||||
|
||||
if (action == "ACCEPT") {
|
||||
logger.debug("Resource allowed by rule");
|
||||
// Public inference still requires a virtual API key; do not
|
||||
// bypass that with an allow rule.
|
||||
if (mode === "inference") {
|
||||
logger.debug(
|
||||
"Rule ACCEPT ignored for inference; continuing to virtual API key check"
|
||||
);
|
||||
} else {
|
||||
logger.debug("Resource allowed by rule");
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 100, // allowed by rule
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 100, // allowed by rule
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res, undefined, dontStripSession);
|
||||
return allowed(res, undefined, dontStripSession);
|
||||
}
|
||||
} else if (action == "DROP") {
|
||||
logger.debug("Resource denied by rule");
|
||||
|
||||
@@ -302,20 +315,23 @@ export async function verifyResourceSession(
|
||||
!emailWhitelistEnabled &&
|
||||
!headerAuth
|
||||
) {
|
||||
logger.debug("Resource allowed because no auth");
|
||||
// Public inference always requires a virtual API key.
|
||||
if (mode !== "inference") {
|
||||
logger.debug("Resource allowed because no auth");
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 101, // allowed no auth
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 101, // allowed no auth
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res, undefined, dontStripSession);
|
||||
return allowed(res, undefined, dontStripSession);
|
||||
}
|
||||
}
|
||||
|
||||
// Only offer a browser redirect to clients that can actually follow one and log in
|
||||
@@ -327,6 +343,82 @@ export async function verifyResourceSession(
|
||||
)}?redirect=${encodeURIComponent(originalRequestURL)}`
|
||||
: undefined;
|
||||
|
||||
// Virtual API keys for public inference resources (provider-style auth headers).
|
||||
// Session/SSO may authenticate users elsewhere (e.g. dashboard key pages), but
|
||||
// only a valid virtual API key is allowed through to the AI gateway.
|
||||
if (mode === "inference") {
|
||||
const vakCredential = extractVirtualApiKeyCredential(headers);
|
||||
if (vakCredential) {
|
||||
const {
|
||||
valid,
|
||||
error,
|
||||
key,
|
||||
userData: vakUserData
|
||||
} = await verifyVirtualApiKey({
|
||||
credential: vakCredential,
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.debug("Virtual API key invalid: " + error);
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Virtual API key is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (valid && key) {
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 109, // valid virtual API key
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC,
|
||||
...(vakUserData
|
||||
? {
|
||||
user: {
|
||||
username: vakUserData.username,
|
||||
userId: vakUserData.userId
|
||||
}
|
||||
}
|
||||
: {
|
||||
apiKey: {
|
||||
name: key.name,
|
||||
apiKeyId: key.virtualApiKeyId
|
||||
}
|
||||
}),
|
||||
metadata: {
|
||||
virtualApiKeyId: key.virtualApiKeyId,
|
||||
virtualApiKeyKind: key.kind
|
||||
}
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res, vakUserData, dontStripSession);
|
||||
}
|
||||
}
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: false,
|
||||
reason: 299, // no more auth methods / VAK required
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return notAllowed(res, redirectPath);
|
||||
}
|
||||
|
||||
// check for access token in headers
|
||||
if (
|
||||
headers &&
|
||||
|
||||
Reference in New Issue
Block a user