fix(api-key): enforce requested product binding

This commit is contained in:
root 2026-03-15 06:01:17 +00:00
parent daec38faf7
commit 841d2f5129
2 changed files with 31 additions and 5 deletions

View File

@ -81,6 +81,30 @@ describe('api key auth', () => {
expect(res.statusCode).toBe(403);
});
it('rejects api key when x-product-id targets another product', async () => {
await seedApiKeyToken(['jobs:read']);
const app = Fastify();
await registerOptionalApiKeyContext(app);
app.get('/probe', async req => {
return requireJwtOrApiKey(req, {
apiKeyScopes: ['jobs:read'],
});
});
const res = await app.inject({
method: 'GET',
url: '/probe',
headers: {
'x-api-key': rawApiKey,
'x-product-id': 'chronomind',
},
});
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).error).toBe('Forbidden');
});
it('rate limits per api key and action key', async () => {
process.env.API_KEY_RATE_LIMIT_CONFIG_JSON = JSON.stringify({
'jobs:write': {

View File

@ -118,6 +118,13 @@ function ensureApiKeyScopes(req: FastifyRequest, requiredScopes: string[] = []):
throw new UnauthorizedError('API key required');
}
const productIdHeader = req.headers['x-product-id'];
if (typeof productIdHeader === 'string' && productIdHeader.length > 0) {
if (productIdHeader !== apiKey.productId) {
throw new ForbiddenError('API key is not valid for the requested product');
}
}
if (
requiredScopes.length > 0 &&
!requiredScopes.every(scope => tokenHasScope(apiKey.scopes, scope))
@ -181,17 +188,12 @@ export async function registerOptionalApiKeyContext(app: FastifyInstance): Promi
if (!rawKey) return;
const prefix = rawKey.slice(0, 12);
const productIdHeader = req.headers['x-product-id'];
const filter: Record<string, unknown> = {
prefix,
status: 'active',
expiresAt: { $gte: new Date().toISOString() },
};
if (typeof productIdHeader === 'string' && productIdHeader.length > 0) {
filter.productId = productIdHeader;
}
const candidates = await tokenCollection().findMany({
filter,
limit: 10,