From e03476872710ab0bfe2e74f54e8fb0c07e24a9d4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:42:48 -0700 Subject: [PATCH] fix(backend): IP-tier limits on the anonymous marketplace reads and gateway health The seven anonymous marketplace reads (apps.ts /marketplace, /featured, /:id; marketplace-api.ts /browse, /manifests/:id, /manifests/:id/forks; marketplace.ts /official) take platformIpRateLimit at 300/min/IP, and /api/health/clawdbot, which calls the agent gateway on each hit, takes 60/min/IP. Threat-model budgets, as the other IP-tier routes: the ingress log shows almost no traffic on these. The route rate-limit guard baseline drops their eight entries, and a behavioural test shows the 301st read gets the named 429. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MCgCsf3UJRCPRbnNKxsECe --- .../unit/routes/marketplace.readLimit.test.js | 22 +++++++++++++++++++ .../routes/routeRateLimitGuard.baseline.json | 8 ------- backend/routes/apps.ts | 17 +++++++++++--- backend/routes/health.ts | 12 +++++++++- backend/routes/marketplace-api.ts | 16 +++++++++++--- backend/routes/marketplace.ts | 13 ++++++++++- 6 files changed, 72 insertions(+), 16 deletions(-) create mode 100644 backend/__tests__/unit/routes/marketplace.readLimit.test.js diff --git a/backend/__tests__/unit/routes/marketplace.readLimit.test.js b/backend/__tests__/unit/routes/marketplace.readLimit.test.js new file mode 100644 index 0000000000..f825be8561 --- /dev/null +++ b/backend/__tests__/unit/routes/marketplace.readLimit.test.js @@ -0,0 +1,22 @@ +// The anonymous marketplace reads take the IP tier (300 per minute per IP). +// The route guard proves a limiter is registered by name; this proves the +// budget trips, on the one read that needs no database. +const request = require('supertest'); +const express = require('express'); + +const marketplaceRoutes = require('../../../routes/marketplace'); + +describe('marketplace read limit', () => { + it('answers 300 reads a minute from one IP, then a named 429', async () => { + const app = express(); + app.use('/api/marketplace', marketplaceRoutes); + for (let i = 0; i < 300; i += 1) { + // eslint-disable-next-line no-await-in-loop + const ok = await request(app).get('/api/marketplace/official'); + expect(ok.status).toBe(200); + } + const res = await request(app).get('/api/marketplace/official'); + expect(res.status).toBe(429); + expect(res.body).toMatchObject({ reason: 'platform_rate_limited', code: 'rate_limited' }); + }); +}); diff --git a/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json b/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json index cb228732a3..26f90b1080 100644 --- a/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json +++ b/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json @@ -36,9 +36,6 @@ "routes/apps.ts DELETE /pods/:podId/apps/:installationId [unlimited]", "routes/apps.ts GET / [unlimited]", "routes/apps.ts GET /:id [unlimited]", - "routes/apps.ts GET /marketplace [unlimited]", - "routes/apps.ts GET /marketplace/:id [unlimited]", - "routes/apps.ts GET /marketplace/featured [unlimited]", "routes/apps.ts GET /pods/:podId/apps [unlimited]", "routes/apps.ts POST / [unlimited]", "routes/apps.ts POST /:id/rotate-secret [unlimited]", @@ -80,7 +77,6 @@ "routes/federation.ts POST /search [unlimited]", "routes/github.ts GET /status [unlimited]", "routes/health.ts GET / [unlimited]", - "routes/health.ts GET /clawdbot [unlimited]", "routes/health.ts GET /db [unlimited]", "routes/health.ts GET /live [unlimited]", "routes/health.ts GET /ready [unlimited]", @@ -97,14 +93,10 @@ "routes/integrations.ts POST /ingest [unlimited]", "routes/marketplace-api.ts DELETE /manifests/:installableId(*) [unlimited]", "routes/marketplace-api.ts DELETE /publish/:installableId(*) [unlimited]", - "routes/marketplace-api.ts GET /browse [unlimited]", - "routes/marketplace-api.ts GET /manifests/:installableId(*) [unlimited]", - "routes/marketplace-api.ts GET /manifests/:installableId(*)/forks [unlimited]", "routes/marketplace-api.ts GET /mine [unlimited]", "routes/marketplace-api.ts POST /fork [unlimited]", "routes/marketplace-api.ts POST /publish [unlimited]", "routes/marketplace-api.ts POST /publish/:installableId(*)/deprecate [unlimited]", - "routes/marketplace.ts GET /official [unlimited]", "routes/messages.ts PATCH /:id/created-at [unlimited]", "routes/pg-status.ts GET / [unlimited]", "routes/pg-status.ts POST /sync-user [unlimited]", diff --git a/backend/routes/apps.ts b/backend/routes/apps.ts index 893f53635c..0a7ce7ac19 100644 --- a/backend/routes/apps.ts +++ b/backend/routes/apps.ts @@ -13,6 +13,8 @@ const Pod = require('../models/Pod'); const AppService = require('../services/appService'); // eslint-disable-next-line global-require const { hash, randomSecret } = require('../utils/secret'); +// eslint-disable-next-line global-require +const { platformIpRateLimit } = require('../middleware/platformRateLimit'); interface AuthReq { user?: { id: string; role?: string }; @@ -51,7 +53,16 @@ const ensurePodAccess = async (podId: string, userId: unknown) => { return pod; }; -router.get('/marketplace', async (req: AuthReq, res: Res) => { +// Anonymous marketplace reads do a database query per hit, so they get the +// IP tier (middleware/platformRateLimit.ts). A threat-model budget, not one +// fitted to traffic: the ingress log shows almost none on these routes. +const marketplaceReadLimit = platformIpRateLimit({ + windowMs: 60 * 1000, + limit: 300, + label: '300 marketplace reads per minute per IP', +}); + +router.get('/marketplace', marketplaceReadLimit, async (req: AuthReq, res: Res) => { try { const { category, type, search, sort, limit, skip } = req.query || {}; const apps = await AppService.getMarketplaceApps({ category, type, search, sort, limit: parseInt(limit || '50', 10) || 50, skip: parseInt(skip || '0', 10) || 0 }); @@ -62,7 +73,7 @@ router.get('/marketplace', async (req: AuthReq, res: Res) => { } }); -router.get('/marketplace/featured', async (req: AuthReq, res: Res) => { +router.get('/marketplace/featured', marketplaceReadLimit, async (req: AuthReq, res: Res) => { try { const limit = parseInt(req.query?.limit || '6', 10) || 6; const apps = await AppService.getFeaturedApps(limit); @@ -73,7 +84,7 @@ router.get('/marketplace/featured', async (req: AuthReq, res: Res) => { } }); -router.get('/marketplace/:id', async (req: AuthReq, res: Res) => { +router.get('/marketplace/:id', marketplaceReadLimit, async (req: AuthReq, res: Res) => { try { const app = await App.findOne({ _id: req.params?.id, 'marketplace.published': true, status: 'active' }).select('-clientSecretHash -webhookSecretHash'); if (!app) return res.status(404).json({ error: 'App not found' }); diff --git a/backend/routes/health.ts b/backend/routes/health.ts index d0de3d6f8e..ece324cd37 100644 --- a/backend/routes/health.ts +++ b/backend/routes/health.ts @@ -3,6 +3,8 @@ const express = require('express'); // eslint-disable-next-line global-require const mongoose = require('mongoose'); // eslint-disable-next-line global-require +const { platformIpRateLimit } = require('../middleware/platformRateLimit'); +// eslint-disable-next-line global-require const { pool: pgPool } = require('../config/db-pg'); // The mount probe, not the boot block's flag — see pgBootService.routerIsMounted // for why those are two different facts (TASK-168). @@ -213,7 +215,15 @@ router.get('/ready', async (_req: unknown, res: Res) => { } }); -router.get('/clawdbot', async (_req: unknown, res: Res) => { +// Anonymous, and each hit makes a request to the agent gateway, so it gets the +// IP tier. The one caller (AgentsHub) asks once per page load. +const gatewayHealthLimit = platformIpRateLimit({ + windowMs: 60 * 1000, + limit: 60, + label: '60 gateway health checks per minute per IP', +}); + +router.get('/clawdbot', gatewayHealthLimit, async (_req: unknown, res: Res) => { const gatewayUrl = process.env.CLAWDBOT_GATEWAY_URL || 'http://clawdbot-gateway:18789'; try { diff --git a/backend/routes/marketplace-api.ts b/backend/routes/marketplace-api.ts index fbae1bbf1c..a86b0aff2d 100644 --- a/backend/routes/marketplace-api.ts +++ b/backend/routes/marketplace-api.ts @@ -1,5 +1,6 @@ const express = require('express'); const auth = require('../middleware/auth'); +const { platformIpRateLimit } = require('../middleware/platformRateLimit'); const Installable = require('../models/Installable'); const { AgentRegistry, AgentInstallation } = require('../models/AgentRegistry'); const { validateMcpComponent } = require('../utils/pluginManifestParser'); @@ -681,10 +682,19 @@ router.post('/publish/:installableId(*)/deprecate', auth, async (req: any, res: } }); +// Anonymous marketplace reads do a database query per hit, so they get the +// IP tier (middleware/platformRateLimit.ts). A threat-model budget, not one +// fitted to traffic: the ingress log shows almost none on these routes. +const marketplaceReadLimit = platformIpRateLimit({ + windowMs: 60 * 1000, + limit: 300, + label: '300 marketplace reads per minute per IP', +}); + /** * GET /api/marketplace/browse */ -router.get('/browse', async (req: any, res: any) => { +router.get('/browse', marketplaceReadLimit, async (req: any, res: any) => { try { const { kind, category, q, sort = 'installs', page = '1', limit = '20', @@ -731,7 +741,7 @@ router.get('/browse', async (req: any, res: any) => { * GET /api/marketplace/manifests/:installableId/forks * Must be registered before the detail route so the wildcard doesn't eat "/forks". */ -router.get('/manifests/:installableId(*)/forks', async (req: any, res: any) => { +router.get('/manifests/:installableId(*)/forks', marketplaceReadLimit, async (req: any, res: any) => { try { const { installableId } = req.params; const page = Math.max(1, parseInt(req.query.page, 10) || 1); @@ -763,7 +773,7 @@ router.get('/manifests/:installableId(*)/forks', async (req: any, res: any) => { /** * GET /api/marketplace/manifests/:installableId */ -router.get('/manifests/:installableId(*)', async (req: any, res: any) => { +router.get('/manifests/:installableId(*)', marketplaceReadLimit, async (req: any, res: any) => { try { const { installableId } = req.params; const doc = await Installable.findOne({ installableId: installableId.toLowerCase() }).lean(); diff --git a/backend/routes/marketplace.ts b/backend/routes/marketplace.ts index c471a42f4e..5047866f7a 100644 --- a/backend/routes/marketplace.ts +++ b/backend/routes/marketplace.ts @@ -5,6 +5,8 @@ import path from 'path'; const express = require('express'); // eslint-disable-next-line global-require const axios = require('axios'); +// eslint-disable-next-line global-require +const { platformIpRateLimit } = require('../middleware/platformRateLimit'); interface MarifestCache { data: { version: string; entries: unknown[] } | null; @@ -68,7 +70,16 @@ const loadManifest = async (): Promise<{ version: string; entries: unknown[] }> const router: ReturnType = express.Router(); -router.get('/official', (_req: unknown, res: { json: (d: unknown) => void; status: (n: number) => { json: (d: unknown) => void } }) => { +// Anonymous marketplace reads do a database query per hit, so they get the +// IP tier (middleware/platformRateLimit.ts). A threat-model budget, not one +// fitted to traffic: the ingress log shows almost none on these routes. +const marketplaceReadLimit = platformIpRateLimit({ + windowMs: 60 * 1000, + limit: 300, + label: '300 marketplace reads per minute per IP', +}); + +router.get('/official', marketplaceReadLimit, (_req: unknown, res: { json: (d: unknown) => void; status: (n: number) => { json: (d: unknown) => void } }) => { loadManifest() .then((manifest) => res.json(manifest)) .catch((error) => {