forked from boos/BoosAPI
default deny empty downstream key exposure
This commit is contained in:
@@ -1,10 +1,17 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getProxyAuthContext } from '../../middleware/auth.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 {
|
||||
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(
|
||||
@@ -14,6 +21,7 @@ export async function ensureModelAllowedForDownstreamKey(
|
||||
): Promise<boolean> {
|
||||
const authContext = getProxyAuthContext(request);
|
||||
if (!authContext) return true;
|
||||
if (authContext.keyId === null) return true;
|
||||
|
||||
if (await isModelAllowedByPolicyOrAllowedRoutes(requestedModel, authContext.policy)) {
|
||||
return true;
|
||||
|
||||
@@ -8,6 +8,7 @@ type DbModule = typeof import('../../db/index.js');
|
||||
type ProxyRouterModule = typeof import('./router.js');
|
||||
type TokenRouterModule = typeof import('../../services/tokenRouter.js');
|
||||
type TokensRoutesModule = typeof import('../api/tokens.js');
|
||||
type ConfigModule = typeof import('../../config.js');
|
||||
|
||||
describe('/v1/models route', () => {
|
||||
let db: DbModule['db'];
|
||||
@@ -15,6 +16,7 @@ describe('/v1/models route', () => {
|
||||
let proxyRoutes: ProxyRouterModule['proxyRoutes'];
|
||||
let tokensRoutes: TokensRoutesModule['tokensRoutes'];
|
||||
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||
let config: ConfigModule['config'];
|
||||
let app: FastifyInstance;
|
||||
let dataDir = '';
|
||||
|
||||
@@ -27,12 +29,15 @@ describe('/v1/models route', () => {
|
||||
const proxyRouterModule = await import('./router.js');
|
||||
const tokenRouterModule = await import('../../services/tokenRouter.js');
|
||||
const tokensRoutesModule = await import('../api/tokens.js');
|
||||
const configModule = await import('../../config.js');
|
||||
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
proxyRoutes = proxyRouterModule.proxyRoutes;
|
||||
tokensRoutes = tokensRoutesModule.tokensRoutes;
|
||||
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||
config = configModule.config;
|
||||
config.proxyToken = 'sk-global-proxy-token';
|
||||
|
||||
app = Fastify();
|
||||
await app.register(tokensRoutes);
|
||||
@@ -108,6 +113,7 @@ describe('/v1/models route', () => {
|
||||
name: 'managed-key',
|
||||
key: 'sk-managed-models',
|
||||
enabled: true,
|
||||
supportedModels: JSON.stringify(['routable-model']),
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -129,6 +135,64 @@ describe('/v1/models route', () => {
|
||||
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 () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'test-site',
|
||||
@@ -290,6 +354,58 @@ describe('/v1/models route', () => {
|
||||
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 () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'explicit-group-site',
|
||||
@@ -461,6 +577,7 @@ describe('/v1/models route', () => {
|
||||
name: 'search-key',
|
||||
key: 'sk-search-key',
|
||||
enabled: true,
|
||||
supportedModels: JSON.stringify(['gpt-4.1']),
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
|
||||
@@ -136,6 +136,17 @@ describe('downstreamApiKeyService', () => {
|
||||
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 () => {
|
||||
const virtualModelGroup = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'claude-opus-4-6',
|
||||
|
||||
@@ -234,7 +234,7 @@ export async function isModelAllowedByPolicyOrAllowedRoutes(model: string, polic
|
||||
const hasPatternRules = patterns.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))) {
|
||||
return true;
|
||||
|
||||
@@ -9,3 +9,9 @@ export const EMPTY_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = {
|
||||
allowedRouteIds: [],
|
||||
siteWeightMultipliers: {},
|
||||
};
|
||||
|
||||
export const GLOBAL_UNRESTRICTED_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = {
|
||||
supportedModels: ['*'],
|
||||
allowedRouteIds: [],
|
||||
siteWeightMultipliers: {},
|
||||
};
|
||||
|
||||
@@ -102,6 +102,46 @@ describe('TokenRouter downstream policy', () => {
|
||||
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 () => {
|
||||
const siteHigh = await db.insert(schema.sites).values({
|
||||
name: 'high-site',
|
||||
|
||||
@@ -535,10 +535,13 @@ function isModelAllowedByDownstreamPolicy(requestedModel: string, policy: Downst
|
||||
const supportedPatterns = Array.isArray(policy.supportedModels)
|
||||
? policy.supportedModels
|
||||
: [];
|
||||
const hasSupportedPatterns = supportedPatterns.length > 0;
|
||||
const hasAllowedRoutes = policy.allowedRouteIds.length > 0;
|
||||
if (!hasSupportedPatterns && !hasAllowedRoutes) return false;
|
||||
const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(requestedModel, pattern));
|
||||
if (matchedSupportedPattern) return true;
|
||||
if (policy.allowedRouteIds.length > 0) return true;
|
||||
return supportedPatterns.length === 0;
|
||||
if (hasAllowedRoutes) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveMappedModel(requestedModel: string, modelMapping?: string | null): string {
|
||||
|
||||
@@ -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 () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
|
||||
@@ -277,13 +277,13 @@ function buildEditorForm(item?: ManagedItem | DownstreamApiKeyItem | null): Edit
|
||||
}
|
||||
|
||||
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];
|
||||
return `${models[0]} +${models.length - 1}`;
|
||||
}
|
||||
|
||||
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
|
||||
.map((id) => routeMap.get(id))
|
||||
.filter(Boolean)
|
||||
@@ -956,11 +956,26 @@ function EditorModal({
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<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>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedModels: [] }))}>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<div className="downstream-key-modal-meta">已选 {selectedModelCount} 个模型</div>
|
||||
<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>
|
||||
<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>
|
||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: [] }))}>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<div className="downstream-key-modal-meta">已选 {selectedGroupCount} 个群组</div>
|
||||
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
|
||||
|
||||
Reference in New Issue
Block a user