Skip to content

Commit f8831aa

Browse files
feat(files): per-IP rate limit on public share endpoints
1 parent 057994f commit f8831aa

4 files changed

Lines changed: 70 additions & 1 deletion

File tree

apps/sim/app/api/files/public/[token]/content/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares'
44
import { parseRequest } from '@/lib/api/server'
55
import { loadServableDocArtifact } from '@/lib/copilot/tools/server/files/doc-compile'
66
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
78
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
89
import { downloadFile } from '@/lib/uploads/core/storage-service'
910
import { createErrorResponse, createFileResponse, FileNotFoundError } from '@/app/api/files/utils'
@@ -26,6 +27,9 @@ const logger = createLogger('PublicFileContentAPI')
2627
export const GET = withRouteHandler(
2728
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
2829
try {
30+
const limited = await enforcePublicFileRateLimit(request, 'content')
31+
if (limited) return limited
32+
2933
const parsed = await parseRequest(getPublicFileContentContract, request, context)
3034
if (!parsed.success) return parsed.response
3135
const { token } = parsed.data.params

apps/sim/app/api/files/public/[token]/route.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@
44
import { NextRequest } from 'next/server'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
const { mockResolveActiveShareByToken } = vi.hoisted(() => ({
7+
const { mockResolveActiveShareByToken, mockEnforceRateLimit } = vi.hoisted(() => ({
88
mockResolveActiveShareByToken: vi.fn(),
9+
mockEnforceRateLimit: vi.fn(),
910
}))
1011

1112
vi.mock('@/lib/public-shares/share-manager', () => ({
1213
resolveActiveShareByToken: mockResolveActiveShareByToken,
1314
}))
1415

16+
vi.mock('@/lib/public-shares/rate-limit', () => ({
17+
enforcePublicFileRateLimit: mockEnforceRateLimit,
18+
}))
19+
20+
import { NextResponse } from 'next/server'
1521
import { GET } from '@/app/api/files/public/[token]/route'
1622

1723
const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) })
@@ -20,6 +26,16 @@ const request = (token = 'tok_1') => new NextRequest(`http://localhost/api/files
2026
describe('GET /api/files/public/[token]', () => {
2127
beforeEach(() => {
2228
vi.clearAllMocks()
29+
mockEnforceRateLimit.mockResolvedValue(null) // allow by default
30+
})
31+
32+
it('returns 429 when the per-IP rate limit is exceeded', async () => {
33+
mockEnforceRateLimit.mockResolvedValueOnce(
34+
NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 })
35+
)
36+
const res = await GET(request(), params())
37+
expect(res.status).toBe(429)
38+
expect(mockResolveActiveShareByToken).not.toHaveBeenCalled()
2339
})
2440

2541
it('returns 404 for an unknown or inactive token', async () => {

apps/sim/app/api/files/public/[token]/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { NextResponse } from 'next/server'
55
import { getPublicFileContract } from '@/lib/api/contracts/public-shares'
66
import { parseRequest } from '@/lib/api/server'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
89
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
910

1011
export const dynamic = 'force-dynamic'
@@ -19,6 +20,9 @@ const logger = createLogger('PublicFileMetadataAPI')
1920
export const GET = withRouteHandler(
2021
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
2122
try {
23+
const limited = await enforcePublicFileRateLimit(request, 'metadata')
24+
if (limited) return limited
25+
2226
const parsed = await parseRequest(getPublicFileContract, request, context)
2327
if (!parsed.success) return parsed.response
2428
const { token } = parsed.data.params
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { NextResponse } from 'next/server'
2+
import { RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter'
3+
import { getClientIp } from '@/lib/core/utils/request'
4+
5+
const rateLimiter = new RateLimiter()
6+
7+
/** Metadata reads are cheap (one indexed lookup) — generous per-IP budget. */
8+
const METADATA_RATE_LIMIT: TokenBucketConfig = {
9+
maxTokens: 120,
10+
refillRate: 120,
11+
refillIntervalMs: 60_000,
12+
}
13+
14+
/** Content reads stream bytes from storage (S3 egress) — tighter per-IP budget. */
15+
const CONTENT_RATE_LIMIT: TokenBucketConfig = {
16+
maxTokens: 60,
17+
refillRate: 60,
18+
refillIntervalMs: 60_000,
19+
}
20+
21+
/**
22+
* Per-IP rate limit for the unauthenticated public share endpoints, returning a
23+
* `429` response when exceeded (or `null` to proceed). The token is unguessable,
24+
* so this defends a *known* link against hammering (DoS / S3 egress) rather than
25+
* enumeration. Fails open on storage errors (availability over strictness),
26+
* matching the chat public route.
27+
*/
28+
export async function enforcePublicFileRateLimit(
29+
request: { headers: { get(name: string): string | null } },
30+
scope: 'metadata' | 'content'
31+
): Promise<NextResponse | null> {
32+
const ip = getClientIp(request)
33+
const config = scope === 'content' ? CONTENT_RATE_LIMIT : METADATA_RATE_LIMIT
34+
const result = await rateLimiter.checkRateLimitDirect(`public-file:${scope}:${ip}`, config)
35+
if (result.allowed) return null
36+
37+
const headers =
38+
result.retryAfterMs != null
39+
? { 'Retry-After': String(Math.ceil(result.retryAfterMs / 1000)) }
40+
: undefined
41+
return NextResponse.json(
42+
{ error: 'Too many requests. Please try again later.' },
43+
{ status: 429, headers }
44+
)
45+
}

0 commit comments

Comments
 (0)