fix: harden web runtime and follow-up auth/db security fixes #21

Merged
sirily merged 4 commits from fix/api-runtime-security-controls into master 2026-03-11 16:28:56 +03:00
3 changed files with 36 additions and 0 deletions
Showing only changes of commit f404c36ed1 - Show all commits

View File

@@ -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: {

View File

@@ -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 {

View File

@@ -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);