1
0
forked from boos/BoosAPI

default deny empty downstream key exposure

This commit is contained in:
cita
2026-03-20 00:22:17 +08:00
parent ee6eeaddeb
commit 5403a946c6
9 changed files with 311 additions and 22 deletions
+10 -2
View File
@@ -1,10 +1,17 @@
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { getProxyAuthContext } from '../../middleware/auth.js'; import { getProxyAuthContext } from '../../middleware/auth.js';
import { isModelAllowedByPolicyOrAllowedRoutes, recordManagedKeyCostUsage } from '../../services/downstreamApiKeyService.js'; import { isModelAllowedByPolicyOrAllowedRoutes, recordManagedKeyCostUsage } from '../../services/downstreamApiKeyService.js';
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js'; import {
EMPTY_DOWNSTREAM_ROUTING_POLICY,
GLOBAL_UNRESTRICTED_DOWNSTREAM_ROUTING_POLICY,
type DownstreamRoutingPolicy,
} from '../../services/downstreamPolicyTypes.js';
export function getDownstreamRoutingPolicy(request: FastifyRequest): DownstreamRoutingPolicy { export function getDownstreamRoutingPolicy(request: FastifyRequest): DownstreamRoutingPolicy {
return getProxyAuthContext(request)?.policy || EMPTY_DOWNSTREAM_ROUTING_POLICY; const authContext = getProxyAuthContext(request);
if (!authContext) return EMPTY_DOWNSTREAM_ROUTING_POLICY;
if (authContext.keyId === null) return GLOBAL_UNRESTRICTED_DOWNSTREAM_ROUTING_POLICY;
return authContext.policy;
} }
export async function ensureModelAllowedForDownstreamKey( export async function ensureModelAllowedForDownstreamKey(
@@ -14,6 +21,7 @@ export async function ensureModelAllowedForDownstreamKey(
): Promise<boolean> { ): Promise<boolean> {
const authContext = getProxyAuthContext(request); const authContext = getProxyAuthContext(request);
if (!authContext) return true; if (!authContext) return true;
if (authContext.keyId === null) return true;
if (await isModelAllowedByPolicyOrAllowedRoutes(requestedModel, authContext.policy)) { if (await isModelAllowedByPolicyOrAllowedRoutes(requestedModel, authContext.policy)) {
return true; return true;
+117
View File
@@ -8,6 +8,7 @@ type DbModule = typeof import('../../db/index.js');
type ProxyRouterModule = typeof import('./router.js'); type ProxyRouterModule = typeof import('./router.js');
type TokenRouterModule = typeof import('../../services/tokenRouter.js'); type TokenRouterModule = typeof import('../../services/tokenRouter.js');
type TokensRoutesModule = typeof import('../api/tokens.js'); type TokensRoutesModule = typeof import('../api/tokens.js');
type ConfigModule = typeof import('../../config.js');
describe('/v1/models route', () => { describe('/v1/models route', () => {
let db: DbModule['db']; let db: DbModule['db'];
@@ -15,6 +16,7 @@ describe('/v1/models route', () => {
let proxyRoutes: ProxyRouterModule['proxyRoutes']; let proxyRoutes: ProxyRouterModule['proxyRoutes'];
let tokensRoutes: TokensRoutesModule['tokensRoutes']; let tokensRoutes: TokensRoutesModule['tokensRoutes'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache']; let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let config: ConfigModule['config'];
let app: FastifyInstance; let app: FastifyInstance;
let dataDir = ''; let dataDir = '';
@@ -27,12 +29,15 @@ describe('/v1/models route', () => {
const proxyRouterModule = await import('./router.js'); const proxyRouterModule = await import('./router.js');
const tokenRouterModule = await import('../../services/tokenRouter.js'); const tokenRouterModule = await import('../../services/tokenRouter.js');
const tokensRoutesModule = await import('../api/tokens.js'); const tokensRoutesModule = await import('../api/tokens.js');
const configModule = await import('../../config.js');
db = dbModule.db; db = dbModule.db;
schema = dbModule.schema; schema = dbModule.schema;
proxyRoutes = proxyRouterModule.proxyRoutes; proxyRoutes = proxyRouterModule.proxyRoutes;
tokensRoutes = tokensRoutesModule.tokensRoutes; tokensRoutes = tokensRoutesModule.tokensRoutes;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache; invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
config = configModule.config;
config.proxyToken = 'sk-global-proxy-token';
app = Fastify(); app = Fastify();
await app.register(tokensRoutes); await app.register(tokensRoutes);
@@ -108,6 +113,7 @@ describe('/v1/models route', () => {
name: 'managed-key', name: 'managed-key',
key: 'sk-managed-models', key: 'sk-managed-models',
enabled: true, enabled: true,
supportedModels: JSON.stringify(['routable-model']),
}).run(); }).run();
const response = await app.inject({ const response = await app.inject({
@@ -129,6 +135,64 @@ describe('/v1/models route', () => {
expect(ids).not.toContain('orphan-model'); expect(ids).not.toContain('orphan-model');
}); });
it('keeps global proxy token unrestricted when no managed key matches', async () => {
const site = await db.insert(schema.sites).values({
name: 'global-site',
url: 'https://global.example.com',
platform: 'openai',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
accessToken: 'global-access-token',
status: 'active',
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'global-api-token',
enabled: true,
isDefault: true,
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'global-routable-model',
available: true,
}).run();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'global-routable-model',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: token.id,
sourceModel: 'global-routable-model',
enabled: true,
}).run();
const response = await app.inject({
method: 'GET',
url: '/v1/models',
headers: {
authorization: 'Bearer sk-global-proxy-token',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
object: 'list';
data: Array<{ id: string }>;
};
expect(body.data.map((item) => item.id)).toContain('global-routable-model');
});
it('returns only whitelist models for managed key with supportedModels policy', async () => { it('returns only whitelist models for managed key with supportedModels policy', async () => {
const site = await db.insert(schema.sites).values({ const site = await db.insert(schema.sites).values({
name: 'test-site', name: 'test-site',
@@ -290,6 +354,58 @@ describe('/v1/models route', () => {
expect(ids).not.toContain('claude-sonnet-4-5'); expect(ids).not.toContain('claude-sonnet-4-5');
}); });
it('returns no models for managed key with empty model and group selections', async () => {
const site = await db.insert(schema.sites).values({
name: 'deny-all-site',
url: 'https://deny-all.example.com',
platform: 'openai',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
accessToken: 'deny-all-access-token',
status: 'active',
}).returning().get();
await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'gpt-4o-mini',
available: true,
},
{
accountId: account.id,
modelName: 'claude-opus-4-6',
available: true,
},
]).run();
await db.insert(schema.downstreamApiKeys).values({
name: 'managed-key-deny-all',
key: 'sk-managed-deny-all',
enabled: true,
supportedModels: JSON.stringify([]),
allowedRouteIds: JSON.stringify([]),
}).run();
const response = await app.inject({
method: 'GET',
url: '/v1/models',
headers: {
authorization: 'Bearer sk-managed-deny-all',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
object: 'list';
data: Array<{ id: string }>;
};
expect(body.data).toEqual([]);
});
it('returns only explicit-group public name while hiding source exact routes', async () => { it('returns only explicit-group public name while hiding source exact routes', async () => {
const site = await db.insert(schema.sites).values({ const site = await db.insert(schema.sites).values({
name: 'explicit-group-site', name: 'explicit-group-site',
@@ -461,6 +577,7 @@ describe('/v1/models route', () => {
name: 'search-key', name: 'search-key',
key: 'sk-search-key', key: 'sk-search-key',
enabled: true, enabled: true,
supportedModels: JSON.stringify(['gpt-4.1']),
}).run(); }).run();
const response = await app.inject({ const response = await app.inject({
@@ -136,6 +136,17 @@ describe('downstreamApiKeyService', () => {
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gemini-2.0-flash', policy)).toBe(false); expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gemini-2.0-flash', policy)).toBe(false);
}); });
it('denies all models when both supportedModels and allowedRouteIds are empty', async () => {
const policy = {
supportedModels: [],
allowedRouteIds: [],
siteWeightMultipliers: {},
};
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(false);
});
it('authorizes by selected group model pattern only, not arbitrary internal models', async () => { it('authorizes by selected group model pattern only, not arbitrary internal models', async () => {
const virtualModelGroup = await db.insert(schema.tokenRoutes).values({ const virtualModelGroup = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-opus-4-6', modelPattern: 'claude-opus-4-6',
@@ -234,7 +234,7 @@ export async function isModelAllowedByPolicyOrAllowedRoutes(model: string, polic
const hasPatternRules = patterns.length > 0; const hasPatternRules = patterns.length > 0;
const hasRouteRules = allowedRouteIds.length > 0; const hasRouteRules = allowedRouteIds.length > 0;
if (!hasPatternRules && !hasRouteRules) return true; if (!hasPatternRules && !hasRouteRules) return false;
if (hasPatternRules && patterns.some((pattern) => matchesDownstreamModelPattern(model, pattern))) { if (hasPatternRules && patterns.some((pattern) => matchesDownstreamModelPattern(model, pattern))) {
return true; return true;
@@ -9,3 +9,9 @@ export const EMPTY_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = {
allowedRouteIds: [], allowedRouteIds: [],
siteWeightMultipliers: {}, siteWeightMultipliers: {},
}; };
export const GLOBAL_UNRESTRICTED_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = {
supportedModels: ['*'],
allowedRouteIds: [],
siteWeightMultipliers: {},
};
@@ -102,6 +102,46 @@ describe('TokenRouter downstream policy', () => {
expect(blockedPick).toBeNull(); expect(blockedPick).toBeNull();
}); });
it('rejects route selection when both supportedModels and allowedRouteIds are empty', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-deny-all',
url: 'https://deny-all.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'user-deny-all',
accessToken: 'access-deny-all',
apiToken: 'sk-deny-all',
status: 'active',
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: null,
priority: 0,
weight: 10,
enabled: true,
}).run();
const router = new TokenRouter();
const deniedPick = await router.selectChannel('gpt-4o-mini', {
allowedRouteIds: [],
supportedModels: [],
siteWeightMultipliers: {},
});
expect(deniedPick).toBeNull();
});
it('applies site weight multipliers to probability explanation', async () => { it('applies site weight multipliers to probability explanation', async () => {
const siteHigh = await db.insert(schema.sites).values({ const siteHigh = await db.insert(schema.sites).values({
name: 'high-site', name: 'high-site',
+12 -9
View File
@@ -531,15 +531,18 @@ function channelSupportsRequestedModel(channelSourceModel: string | null | undef
return false; return false;
} }
function isModelAllowedByDownstreamPolicy(requestedModel: string, policy: DownstreamRoutingPolicy): boolean { function isModelAllowedByDownstreamPolicy(requestedModel: string, policy: DownstreamRoutingPolicy): boolean {
const supportedPatterns = Array.isArray(policy.supportedModels) const supportedPatterns = Array.isArray(policy.supportedModels)
? policy.supportedModels ? policy.supportedModels
: []; : [];
const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(requestedModel, pattern)); const hasSupportedPatterns = supportedPatterns.length > 0;
if (matchedSupportedPattern) return true; const hasAllowedRoutes = policy.allowedRouteIds.length > 0;
if (policy.allowedRouteIds.length > 0) return true; if (!hasSupportedPatterns && !hasAllowedRoutes) return false;
return supportedPatterns.length === 0; const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(requestedModel, pattern));
} if (matchedSupportedPattern) return true;
if (hasAllowedRoutes) return true;
return false;
}
function resolveMappedModel(requestedModel: string, modelMapping?: string | null): string { function resolveMappedModel(requestedModel: string, modelMapping?: string | null): string {
if (!modelMapping) return requestedModel; if (!modelMapping) return requestedModel;
+74
View File
@@ -404,6 +404,80 @@ describe('DownstreamKeys page', () => {
} }
}); });
it('lets operators explicitly select all exact models and all group routes before saving', async () => {
apiMock.getRoutesLite.mockResolvedValue([
{ id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true },
{ id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
{ id: 13, modelPattern: 're:^gemini-2\\..*$', displayName: 'Gemini 全家桶', enabled: true },
{ id: 14, modelPattern: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', enabled: true },
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0];
await act(async () => {
advancedBtn.props.onClick();
});
await flushMicrotasks();
const panels = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-panel');
const modelPanel = panels.find((node) => collectText(node).includes('模型白名单'));
const groupPanel = panels.find((node) => collectText(node).includes('群组范围'));
expect(modelPanel).toBeTruthy();
expect(groupPanel).toBeTruthy();
const modelSelectAllBtn = modelPanel!.findAll((node) => node.type === 'button' && collectText(node).includes('全选'))[0];
const groupSelectAllBtn = groupPanel!.findAll((node) => node.type === 'button' && collectText(node).includes('全选'))[0];
await act(async () => {
modelSelectAllBtn.props.onClick();
groupSelectAllBtn.props.onClick();
});
await flushMicrotasks();
const inputs = root!.root.findAllByType('input');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
await act(async () => {
nameInput!.props.onChange({ target: { value: 'select-all-key' } });
keyInput!.props.onChange({ target: { value: 'sk-select-all-key-0319' } });
});
await flushMicrotasks();
const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0];
await act(async () => {
saveBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({
name: 'select-all-key',
key: 'sk-select-all-key-0319',
supportedModels: ['claude-opus-4-6', 'gpt-4.1-mini'],
allowedRouteIds: [11, 13],
}));
} finally {
root?.unmount();
}
});
it('uses backend batch api for selected rows', async () => { it('uses backend batch api for selected rows', async () => {
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try { try {
+40 -10
View File
@@ -277,13 +277,13 @@ function buildEditorForm(item?: ManagedItem | DownstreamApiKeyItem | null): Edit
} }
function summarizeModelLimit(models: string[]): string { function summarizeModelLimit(models: string[]): string {
if (!Array.isArray(models) || models.length === 0) return '全部模型'; if (!Array.isArray(models) || models.length === 0) return '未授权模型';
if (models.length === 1) return models[0]; if (models.length === 1) return models[0];
return `${models[0]} +${models.length - 1}`; return `${models[0]} +${models.length - 1}`;
} }
function summarizeRouteLimit(routeIds: number[], routeMap: Map<number, RouteSelectorItem>): string { function summarizeRouteLimit(routeIds: number[], routeMap: Map<number, RouteSelectorItem>): string {
if (!Array.isArray(routeIds) || routeIds.length === 0) return '全部群组'; if (!Array.isArray(routeIds) || routeIds.length === 0) return '未授权群组';
const names = routeIds const names = routeIds
.map((id) => routeMap.get(id)) .map((id) => routeMap.get(id))
.filter(Boolean) .filter(Boolean)
@@ -956,11 +956,26 @@ function EditorModal({
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div> <div>
<div className="downstream-key-modal-section-title">模型白名单</div> <div className="downstream-key-modal-section-title">模型白名单</div>
<div className="downstream-key-modal-help">只展示精确模型,未勾选则视为全部模型可用</div> <div className="downstream-key-modal-help">只展示精确模型;未勾选时默认不允许任何精确模型,可点“全选”一次性放开。</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedModels: exactModels }))}
>
全选
</button>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedModels: [] }))}
>
清空
</button>
</div> </div>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedModels: [] }))}>
清空
</button>
</div> </div>
<div className="downstream-key-modal-meta">已选 {selectedModelCount} 个模型</div> <div className="downstream-key-modal-meta">已选 {selectedModelCount} 个模型</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}> <div className="toolbar-search" style={{ maxWidth: '100%' }}>
@@ -995,11 +1010,26 @@ function EditorModal({
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div> <div>
<div className="downstream-key-modal-section-title">群组范围</div> <div className="downstream-key-modal-section-title">群组范围</div>
<div className="downstream-key-modal-help">限制可访问的群组路由,未勾选则视为全部群组可用</div> <div className="downstream-key-modal-help">限制可访问的群组路由;未勾选时默认不允许任何群组,可点“全选”一次性放开。</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: groupRouteOptions.map((route) => route.id) }))}
>
全选
</button>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: [] }))}
>
清空
</button>
</div> </div>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: [] }))}>
清空
</button>
</div> </div>
<div className="downstream-key-modal-meta">已选 {selectedGroupCount} 个群组</div> <div className="downstream-key-modal-meta">已选 {selectedGroupCount} 个群组</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}> <div className="toolbar-search" style={{ maxWidth: '100%' }}>