-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix: security audit — plugin RCE, CORS, token leak, shell exec, login rate limit #1106
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
Draft
wjc2821296948
wants to merge
8
commits into
siteboon:main
Choose a base branch
from
wjc2821296948:fix/security-audit-pr
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from 5 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
55d126e
fix(plugins): disable auto-running npm run build during plugin install
wjc2821296948 15dbf2b
fix(projects): sanitize GitHub tokens from clone progress stream
wjc2821296948 990ede3
fix(server): restrict CORS to same host:port as the server
wjc2821296948 c5bb982
fix(system): spawn update commands without a shell
wjc2821296948 03e6876
fix(auth): rate-limit login and registration per client
wjc2821296948 1b48532
fix(auth): make rate-limit Retry-After reflect the next permitted req…
wjc2821296948 8d83216
fix(projects): redact GitHub tokens split across stdout/stderr chunks
wjc2821296948 6ae7bbd
fix(plugins): stage plugin updates so a rejected update leaves the li…
wjc2821296948 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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| // rate-limit middleware exposes a single factory that the auth router mounts | ||
| // on the login and register endpoints. Keeping the failure path explicit (no | ||
| // shared store) so the limit applies per-process without coupling to the | ||
| // persistence layer. | ||
| import type { Request, RequestHandler, Response } from 'express'; | ||
|
|
||
| type RateLimiterOptions = { | ||
| /** Maximum number of attempts allowed within the rolling window. */ | ||
| maxAttempts: number; | ||
| /** Length of the rolling window, in milliseconds. */ | ||
| windowMs: number; | ||
| /** How long a locked-out client should be told to wait before retrying. */ | ||
| lockoutMs?: number; | ||
| /** Optional clock for tests. */ | ||
| now?: () => number; | ||
| }; | ||
|
|
||
| type AttemptRecord = { | ||
| /** Timestamps (ms) of attempts that fall inside the rolling window. */ | ||
| timestamps: number[]; | ||
| /** Earliest time at which the client may try again after a lockout. */ | ||
| blockedUntil: number; | ||
| }; | ||
|
|
||
| function readClientKey(req: Request): string { | ||
| // Prefer the address of the TCP peer; fall back to a header chain that | ||
| // includes the most common reverse-proxy forwarded-for conventions. | ||
| const socketAddress = req.socket?.remoteAddress; | ||
| if (socketAddress && socketAddress !== '::1' && socketAddress !== '127.0.0.1') { | ||
| return socketAddress; | ||
| } | ||
|
|
||
| const forwarded = req.headers['x-forwarded-for']; | ||
| if (typeof forwarded === 'string' && forwarded.trim()) { | ||
| return forwarded.split(',')[0]!.trim(); | ||
| } | ||
| if (Array.isArray(forwarded) && forwarded.length > 0) { | ||
| return forwarded[0]!.split(',')[0]!.trim(); | ||
| } | ||
|
|
||
| return socketAddress || 'unknown'; | ||
| } | ||
|
|
||
| /** | ||
| * Builds a per-key sliding-window rate limiter suitable for protecting the | ||
| * login and registration endpoints against credential-stuffing attempts. | ||
| * | ||
| * The limiter keeps an in-memory `Map` of client-key → attempt history. Each | ||
| * incoming request drops expired timestamps from the history; if the count | ||
| * after dropping exceeds `maxAttempts`, the request is rejected with HTTP | ||
| * 429 until enough time has elapsed for at least one slot to expire. | ||
| */ | ||
| export function createRateLimiter(options: RateLimiterOptions): { | ||
| middleware: RequestHandler; | ||
| reset: () => void; | ||
| } { | ||
| const maxAttempts = options.maxAttempts; | ||
| const windowMs = options.windowMs; | ||
| const lockoutMs = options.lockoutMs ?? windowMs; | ||
| const clock = options.now ?? (() => Date.now()); | ||
|
|
||
| const records = new Map<string, AttemptRecord>(); | ||
|
|
||
| const middleware: RequestHandler = (req: Request, res: Response, next) => { | ||
| const clientKey = readClientKey(req); | ||
| const now = clock(); | ||
| let record = records.get(clientKey); | ||
| if (!record) { | ||
| record = { timestamps: [], blockedUntil: 0 }; | ||
| records.set(clientKey, record); | ||
| } | ||
|
|
||
| // An active lockout short-circuits the limiter; do not consume an attempt | ||
| // slot so a misbehaving client cannot extend the lockout indefinitely. | ||
| if (record.blockedUntil > now) { | ||
| const retryAfterSeconds = Math.max(1, Math.ceil((record.blockedUntil - now) / 1000)); | ||
| res.setHeader('Retry-After', String(retryAfterSeconds)); | ||
| res.status(429).json({ | ||
| success: false, | ||
| error: { | ||
| code: 'RATE_LIMITED', | ||
| message: 'Too many attempts. Please try again later.', | ||
| retryAfterSeconds, | ||
| }, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Drop timestamps that have aged out of the rolling window. | ||
| const cutoff = now - windowMs; | ||
| record.timestamps = record.timestamps.filter((timestamp) => timestamp > cutoff); | ||
|
|
||
| if (record.timestamps.length >= maxAttempts) { | ||
| record.blockedUntil = now + lockoutMs; | ||
| const retryAfterSeconds = Math.max(1, Math.ceil(lockoutMs / 1000)); | ||
| res.setHeader('Retry-After', String(retryAfterSeconds)); | ||
| res.status(429).json({ | ||
| success: false, | ||
| error: { | ||
| code: 'RATE_LIMITED', | ||
| message: 'Too many attempts. Please try again later.', | ||
| retryAfterSeconds, | ||
| }, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| record.timestamps.push(now); | ||
| next(); | ||
| }; | ||
|
|
||
| return { | ||
| middleware, | ||
| reset: () => records.clear(), | ||
| }; | ||
| } | ||
170 changes: 170 additions & 0 deletions
170
server/modules/auth/tests/rate-limit.middleware.test.ts
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,170 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import test from 'node:test'; | ||
|
|
||
| import { createRateLimiter } from '../rate-limit.middleware.js'; | ||
|
|
||
| function createMockResponse(): { | ||
| statusCode: number; | ||
| body: unknown; | ||
| headers: Record<string, string>; | ||
| status(code: number): typeof response; | ||
| setHeader(name: string, value: string): void; | ||
| json(body: unknown): typeof response; | ||
| } { | ||
| const response = { | ||
| statusCode: 200, | ||
| body: undefined as unknown, | ||
| headers: {} as Record<string, string>, | ||
| status(code: number) { | ||
| this.statusCode = code; | ||
| return this; | ||
| }, | ||
| setHeader(name: string, value: string) { | ||
| this.headers[name] = value; | ||
| }, | ||
| json(body: unknown) { | ||
| this.body = body; | ||
| return this; | ||
| }, | ||
| }; | ||
| return response; | ||
| } | ||
|
|
||
| function createMockRequest(clientKey: string): { | ||
| socket?: { remoteAddress?: string }; | ||
| headers: Record<string, string>; | ||
| } { | ||
| return { | ||
| socket: { remoteAddress: clientKey }, | ||
| headers: {}, | ||
| }; | ||
| } | ||
|
|
||
| test('requests below the attempt cap pass through', () => { | ||
| const limiter = createRateLimiter({ maxAttempts: 3, windowMs: 1000, now: () => 1000 }); | ||
| const next = (() => { calls.push(1); }) as () => void; | ||
| const calls: number[] = []; | ||
|
|
||
| for (let i = 0; i < 3; i += 1) { | ||
| limiter.middleware(createMockRequest('203.0.113.1') as never, createMockResponse() as never, next); | ||
| } | ||
|
|
||
| assert.equal(calls.length, 3); | ||
| }); | ||
|
|
||
| test('the request that trips the limit is rejected with 429 + Retry-After', () => { | ||
| let currentTime = 1000; | ||
| const limiter = createRateLimiter({ | ||
| maxAttempts: 2, | ||
| windowMs: 1000, | ||
| lockoutMs: 5000, | ||
| now: () => currentTime, | ||
| }); | ||
| const next = () => undefined; | ||
| const blocked: { status: number; body: unknown; headers: Record<string, string> } = { | ||
| status: 200, | ||
| body: undefined, | ||
| headers: {}, | ||
| }; | ||
| const response = { | ||
| statusCode: 0, | ||
| body: undefined as unknown, | ||
| headers: {} as Record<string, string>, | ||
| status(code: number) { this.statusCode = code; return this; }, | ||
| setHeader(name: string, value: string) { this.headers[name] = value; }, | ||
| json(body: unknown) { this.body = body; return this; }, | ||
| }; | ||
|
|
||
| // Two successful requests. | ||
| limiter.middleware(createMockRequest('203.0.113.2') as never, response as never, next); | ||
| limiter.middleware(createMockRequest('203.0.113.2') as never, response as never, next); | ||
|
|
||
| // Third request trips the limiter. | ||
| limiter.middleware(createMockRequest('203.0.113.2') as never, response as never, next); | ||
|
|
||
| assert.equal(response.statusCode, 429); | ||
| assert.equal(response.headers['Retry-After'], '5'); | ||
| assert.ok(response.body && typeof response.body === 'object'); | ||
| assert.equal((response.body as { success: boolean }).success, false); | ||
| }); | ||
|
|
||
| test('lockout does not extend when the client keeps hammering', () => { | ||
| let currentTime = 1000; | ||
| const limiter = createRateLimiter({ | ||
| maxAttempts: 1, | ||
| windowMs: 1000, | ||
| lockoutMs: 2000, | ||
| now: () => currentTime, | ||
| }); | ||
| const next = () => undefined; | ||
| const response = { | ||
| statusCode: 0, | ||
| body: undefined as unknown, | ||
| headers: {} as Record<string, string>, | ||
| status(code: number) { this.statusCode = code; return this; }, | ||
| setHeader(name: string, value: string) { this.headers[name] = value; }, | ||
| json(body: unknown) { this.body = body; return this; }, | ||
| }; | ||
|
|
||
| // First request consumes the only slot. | ||
| limiter.middleware(createMockRequest('203.0.113.3') as never, response as never, next); | ||
| // Next request trips the limit and starts a lockout. | ||
| limiter.middleware(createMockRequest('203.0.113.3') as never, response as never, next); | ||
| const firstBlockEnd = response.headers['Retry-After']; | ||
|
|
||
| // Advance time by half a second — still inside the lockout — and verify the | ||
| // block window does NOT extend (would happen if we kept consuming slots). | ||
| currentTime += 500; | ||
| limiter.middleware(createMockRequest('203.0.113.3') as never, response as never, next); | ||
|
|
||
| assert.equal(firstBlockEnd, '2'); | ||
| assert.equal(response.statusCode, 429); | ||
| }); | ||
|
wjc2821296948 marked this conversation as resolved.
Outdated
|
||
|
|
||
| test('rolling window lets the client through once old attempts age out', () => { | ||
| let currentTime = 1000; | ||
| const limiter = createRateLimiter({ | ||
| maxAttempts: 2, | ||
| windowMs: 1000, | ||
| now: () => currentTime, | ||
| }); | ||
| const next = (() => { calls.push(1); }) as () => void; | ||
| const calls: number[] = []; | ||
| const response = { | ||
| statusCode: 0, | ||
| body: undefined as unknown, | ||
| headers: {} as Record<string, string>, | ||
| status(code: number) { this.statusCode = code; return this; }, | ||
| setHeader(name: string, value: string) { this.headers[name] = value; }, | ||
| json(body: unknown) { this.body = body; return this; }, | ||
| }; | ||
|
|
||
| limiter.middleware(createMockRequest('203.0.113.4') as never, response as never, next); | ||
| limiter.middleware(createMockRequest('203.0.113.4') as never, response as never, next); | ||
| limiter.middleware(createMockRequest('203.0.113.4') as never, response as never, next); | ||
| assert.equal(calls.length, 2); | ||
|
|
||
| // Advance past the window so the earlier timestamps drop off. | ||
| currentTime += 1100; | ||
| limiter.middleware(createMockRequest('203.0.113.4') as never, response as never, next); | ||
| assert.equal(calls.length, 3); | ||
| }); | ||
|
|
||
| test('different client keys are tracked independently', () => { | ||
| const limiter = createRateLimiter({ maxAttempts: 1, windowMs: 1000, now: () => 1000 }); | ||
| const next = (() => { calls.push(1); }) as () => void; | ||
| const calls: number[] = []; | ||
| const response = { | ||
| statusCode: 0, | ||
| body: undefined as unknown, | ||
| headers: {} as Record<string, string>, | ||
| status(code: number) { this.statusCode = code; return this; }, | ||
| setHeader(name: string, value: string) { this.headers[name] = value; }, | ||
| json(body: unknown) { this.body = body; return this; }, | ||
| }; | ||
|
|
||
| limiter.middleware(createMockRequest('203.0.113.5') as never, response as never, next); | ||
| limiter.middleware(createMockRequest('203.0.113.6') as never, response as never, next); | ||
|
|
||
| assert.equal(calls.length, 2); | ||
| }); | ||
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.