fix: close remaining web api security gaps
This commit is contained in:
@@ -58,6 +58,23 @@ test("InMemoryRateLimiter blocks after the configured request budget is exhauste
|
|||||||
assert.equal(blocked.retryAfterSeconds, 58);
|
assert.equal(blocked.retryAfterSeconds, 58);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("InMemoryRateLimiter drops expired buckets during periodic sweeps", () => {
|
||||||
|
const limiter = new InMemoryRateLimiter();
|
||||||
|
const policy = {
|
||||||
|
id: "auth",
|
||||||
|
windowMs: 1_000,
|
||||||
|
maxRequests: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(limiter.consume(policy, "stale", 1_000), { allowed: true });
|
||||||
|
|
||||||
|
for (let index = 0; index < 99; index += 1) {
|
||||||
|
limiter.consume(policy, `fresh-${index}`, 3_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(limiter.consume(policy, "stale", 3_000), { allowed: true });
|
||||||
|
});
|
||||||
|
|
||||||
test("assertTrustedOrigin accepts configured origins and requires browser origin metadata", () => {
|
test("assertTrustedOrigin accepts configured origins and requires browser origin metadata", () => {
|
||||||
const request = createMockRequest({
|
const request = createMockRequest({
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -66,8 +66,15 @@ export async function readJsonBody(
|
|||||||
|
|
||||||
export class InMemoryRateLimiter {
|
export class InMemoryRateLimiter {
|
||||||
private readonly entries = new Map<string, RateLimitEntry>();
|
private readonly entries = new Map<string, RateLimitEntry>();
|
||||||
|
private sweepCounter = 0;
|
||||||
|
|
||||||
consume(policy: RateLimitPolicy, key: string, now = Date.now()): RateLimitDecision {
|
consume(policy: RateLimitPolicy, key: string, now = Date.now()): RateLimitDecision {
|
||||||
|
this.sweepCounter += 1;
|
||||||
|
|
||||||
|
if (this.sweepCounter % 100 === 0) {
|
||||||
|
this.deleteExpiredEntries(now);
|
||||||
|
}
|
||||||
|
|
||||||
const bucketKey = `${policy.id}:${key}`;
|
const bucketKey = `${policy.id}:${key}`;
|
||||||
const existing = this.entries.get(bucketKey);
|
const existing = this.entries.get(bucketKey);
|
||||||
|
|
||||||
@@ -90,6 +97,14 @@ export class InMemoryRateLimiter {
|
|||||||
this.entries.set(bucketKey, existing);
|
this.entries.set(bucketKey, existing);
|
||||||
return { allowed: true };
|
return { allowed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private deleteExpiredEntries(now: number): void {
|
||||||
|
for (const [bucketKey, entry] of this.entries.entries()) {
|
||||||
|
if (entry.resetAt <= now) {
|
||||||
|
this.entries.delete(bucketKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRateLimitClientIp(request: IncomingMessage): string {
|
export function getRateLimitClientIp(request: IncomingMessage): string {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ const server = createServer(async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.method === "POST" && request.url === "/api/auth/register") {
|
if (request.method === "POST" && request.url === "/api/auth/register") {
|
||||||
|
assertTrustedOrigin(request, mutationAllowedOrigins);
|
||||||
enforceRateLimit(request, authRateLimitPolicy);
|
enforceRateLimit(request, authRateLimitPolicy);
|
||||||
const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes });
|
const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes });
|
||||||
const payload = readAuthPayload(body);
|
const payload = readAuthPayload(body);
|
||||||
@@ -89,6 +90,7 @@ const server = createServer(async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.method === "POST" && request.url === "/api/auth/login") {
|
if (request.method === "POST" && request.url === "/api/auth/login") {
|
||||||
|
assertTrustedOrigin(request, mutationAllowedOrigins);
|
||||||
enforceRateLimit(request, authRateLimitPolicy);
|
enforceRateLimit(request, authRateLimitPolicy);
|
||||||
const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes });
|
const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes });
|
||||||
const payload = readAuthPayload(body);
|
const payload = readAuthPayload(body);
|
||||||
@@ -108,6 +110,7 @@ const server = createServer(async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.method === "POST" && request.url === "/api/auth/password-reset/request") {
|
if (request.method === "POST" && request.url === "/api/auth/password-reset/request") {
|
||||||
|
assertTrustedOrigin(request, mutationAllowedOrigins);
|
||||||
enforceRateLimit(request, authRateLimitPolicy);
|
enforceRateLimit(request, authRateLimitPolicy);
|
||||||
const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes });
|
const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes });
|
||||||
const payload = readEmailOnlyPayload(body);
|
const payload = readEmailOnlyPayload(body);
|
||||||
@@ -137,6 +140,7 @@ const server = createServer(async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.method === "POST" && request.url === "/api/auth/password-reset/confirm") {
|
if (request.method === "POST" && request.url === "/api/auth/password-reset/confirm") {
|
||||||
|
assertTrustedOrigin(request, mutationAllowedOrigins);
|
||||||
enforceRateLimit(request, authRateLimitPolicy);
|
enforceRateLimit(request, authRateLimitPolicy);
|
||||||
const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes });
|
const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes });
|
||||||
const payload = readPasswordResetConfirmPayload(body);
|
const payload = readPasswordResetConfirmPayload(body);
|
||||||
|
|||||||
Reference in New Issue
Block a user