Repository navigation
fix(registry): keep the catalog rate limiter on catalog routes only #2968
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+180
−12
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
f72bd24
fix(registry): keep the catalog rate limiter on catalog routes only
lilyshen0722 560091b
fix(registry): keep rate limits on the routers mounted after the catalog
lilyshen0722 61dd50e
fix(registry): key the avatar limiter on the token auth resolves
lilyshen0722 753a237
test(registry): prove the shared avatar budget through the route
lilyshen0722 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
44 changes: 44 additions & 0 deletions
44
backend/__tests__/unit/routes/registry.catalog-rate-limit-scope.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| /** | ||
| * D175 — the catalog's IP limiter must stay on the catalog's own routes. | ||
| * | ||
| * registry.ts mounts catalogRouter ahead of pod-agents, files, install and | ||
| * the rest. A `catalogRouter.use(limiter)` therefore ran for EVERY | ||
| * /api/registry/* request that reached it, and /v2/agents (one | ||
| * `/pods/:podId/agents` read per pod) hit 429 on all 62 calls from its third | ||
| * load on a 62-pod account (prod, 10-28). The limiter skips itself under | ||
| * NODE_ENV=test, so this guards the shape instead: catalogRouter carries no | ||
| * router-level middleware, and every route it declares runs the limiter. | ||
| */ | ||
|
|
||
| jest.mock('../../../middleware/auth', () => jest.fn((req, res, next) => next())); | ||
| jest.mock('../../../models/Pod', () => ({})); | ||
| jest.mock('../../../models/AgentRegistry', () => ({ | ||
| AgentRegistry: { search: jest.fn(), getByName: jest.fn() }, | ||
| AgentInstallation: { find: jest.fn(), getInstalledAgents: jest.fn() }, | ||
| })); | ||
|
|
||
| const catalogRouter = require('../../../routes/registry/catalog'); | ||
|
|
||
| describe('catalog rate limiter scope (D175)', () => { | ||
| const layers = catalogRouter.stack; | ||
|
|
||
| it('has no router-level middleware that would run for later registry routers', () => { | ||
| const routerLevel = layers.filter((layer) => !layer.route); | ||
| expect(routerLevel).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('runs the same limiter on every catalog route, ahead of auth', () => { | ||
| const routes = layers.filter((layer) => layer.route); | ||
| expect(routes.length).toBeGreaterThan(0); | ||
| const firstHandlers = routes.map((layer) => layer.route.stack[0].handle); | ||
| const limiter = firstHandlers[0]; | ||
| expect(firstHandlers.every((handle) => handle === limiter)).toBe(true); | ||
| // express-rate-limit's middleware exposes resetKey; auth does not. | ||
| expect(typeof limiter.resetKey).toBe('function'); | ||
| }); | ||
|
|
||
| it('does not declare /pods/:podId/agents, which belongs to pod-agents', () => { | ||
| const paths = layers.filter((layer) => layer.route).map((layer) => layer.route.path); | ||
| expect(paths).not.toContain('/pods/:podId/agents'); | ||
| }); | ||
| }); |
73 changes: 73 additions & 0 deletions
73
backend/__tests__/unit/routes/registry.rate-limit-coverage.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| /** | ||
| * D175 follow-up: scoping the catalog limiter to catalog routes must not strip | ||
| * rate limiting from the routers mounted after it. They used to inherit the | ||
| * catalog's router-level limiter; they now share a registry-wide IP limiter | ||
| * mounted right after the catalog, and generate-avatar (which calls a paid | ||
| * image provider per request) also carries its own per-caller limiter. | ||
| * Limiters skip themselves under NODE_ENV=test, so these guard the shape. | ||
| */ | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
|
|
||
| jest.mock('../../../middleware/auth', () => jest.fn((req, res, next) => next())); | ||
| jest.mock('../../../models/AgentRegistry', () => ({ AgentRegistry: {} })); | ||
| jest.mock('../../../services/agentIdentityService', () => ({})); | ||
| jest.mock('../../../routes/registry/helpers', () => ({ getUserId: jest.fn(), resolveUsername: jest.fn() })); | ||
| jest.mock('../../../utils/agentManifestRegistry', () => ({ | ||
| ManifestValidationError: class extends Error {}, | ||
| normalizePublishPayload: jest.fn(), | ||
| })); | ||
|
|
||
| describe('registry rate-limit coverage (D175)', () => { | ||
| it('mounts a registry-wide limiter directly after the catalog router', () => { | ||
| const src = fs.readFileSync(path.join(__dirname, '../../../routes/registry.ts'), 'utf8'); | ||
| const mounts = [...src.matchAll(/router\.use\((require\('\.\/registry\/([a-z-]+)'\)|rateLimit\()/g)] | ||
| .map((m) => m[2] || 'LIMITER'); | ||
| const catalogAt = mounts.indexOf('catalog'); | ||
| expect(catalogAt).toBeGreaterThan(-1); | ||
| expect(mounts[catalogAt + 1]).toBe('LIMITER'); | ||
| expect(mounts.filter((m) => m === 'LIMITER')).toHaveLength(1); | ||
| }); | ||
|
|
||
| it('runs a limiter before auth on POST /generate-avatar', () => { | ||
| const publishRouter = require('../../../routes/registry/publish'); | ||
| const layer = publishRouter.stack.find((l) => l.route && l.route.path === '/generate-avatar'); | ||
| expect(layer).toBeDefined(); | ||
| expect(typeof layer.route.stack[0].handle.resetKey).toBe('function'); | ||
| }); | ||
|
|
||
| it('gives every spelling of one token one shared avatar budget, through the route', async () => { | ||
| // The limiter skips itself under NODE_ENV=test and reads it per request, | ||
| // so lift that for this test only. auth is mocked to pass; the limiter | ||
| // runs ahead of it, so the budget it enforces is the one under test. | ||
| const express = require('express'); | ||
| const request = require('supertest'); | ||
| const publishRouter = require('../../../routes/registry/publish'); | ||
| const app = express(); | ||
| app.use(express.json()); | ||
| app.use(publishRouter); | ||
| const spellings = [ | ||
| { Authorization: 'Bearer cm_avatar_budget' }, | ||
| { Authorization: 'cm_avatar_budget' }, | ||
| { 'x-auth-token': 'cm_avatar_budget' }, | ||
| ]; | ||
| const saved = process.env.NODE_ENV; | ||
| process.env.NODE_ENV = 'production'; | ||
| try { | ||
| const statuses = []; | ||
| for (let i = 0; i < 11; i += 1) { | ||
| // An empty body is answered 400 by the handler, after the limiter has counted it. | ||
| // eslint-disable-next-line no-await-in-loop | ||
| const res = await request(app).post('/generate-avatar').set(spellings[i % 3]).send({}); | ||
| statuses.push(res.status); | ||
| } | ||
| expect(statuses.slice(0, 10).every((status) => status === 400)).toBe(true); | ||
| expect(statuses[10]).toBe(429); | ||
|
|
||
| const other = await request(app).post('/generate-avatar').set({ Authorization: 'Bearer cm_someone_else' }).send({}); | ||
| expect(other.status).toBe(400); | ||
| } finally { | ||
| process.env.NODE_ENV = saved; | ||
| } | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.