Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions backend/__tests__/unit/routes/marketplace.readLimit.test.js
Original file line number Diff line number Diff line change
@@ -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' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand Down Expand Up @@ -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]",
Expand All @@ -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]",
Expand Down
17 changes: 14 additions & 3 deletions backend/routes/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 });
Expand All @@ -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);
Expand All @@ -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' });
Expand Down
12 changes: 11 additions & 1 deletion backend/routes/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 13 additions & 3 deletions backend/routes/marketplace-api.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
13 changes: 12 additions & 1 deletion backend/routes/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,7 +70,16 @@ const loadManifest = async (): Promise<{ version: string; entries: unknown[] }>

const router: ReturnType<typeof express.Router> = 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) => {
Expand Down
Loading