Add API security guards, rate limiter, and manual test checklist

Implement strict CORS enforcement and per-IP rate limiting in the Next.js middleware. Add input validation helpers for
coordinates and request body size limits. Introduce SSRF protection for calendar URL fetching. Update mobile settings to
support new transport options and arrival buffers. Include a comprehensive manual testing checklist for integration
verification.
This commit is contained in:
2026-05-12 14:42:11 +02:00
parent 863996f06c
commit 35971596b3
21 changed files with 1096 additions and 124 deletions
+94 -38
View File
@@ -1,10 +1,10 @@
import { NextRequest } from 'next/server';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import proxy from '../proxy';
import { NextRequest } from "next/server";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import proxy from "../proxy";
// Mock NextResponse to avoid internal routing context issues
vi.mock('next/server', async (importOriginal) => {
const actual = await importOriginal()
vi.mock("next/server", async (importOriginal) => {
const actual = await importOriginal();
// Create a mock constructor class with static methods
class MockNextResponse {
@@ -14,81 +14,137 @@ vi.mock('next/server', async (importOriginal) => {
public body: BodyInit | null;
static next: () => MockNextResponse;
static json: () => void;
static json: (data: unknown, init?: ResponseInit) => MockNextResponse;
static redirect: () => void;
constructor(_init?: ResponseInit) {
constructor(init?: ResponseInit) {
this.headers = new Headers();
this.status = _init?.status ?? 200;
this.statusText = 'OK';
this.status = init?.status ?? 200;
this.statusText = "OK";
this.body = null;
}
clone() { return new MockNextResponse(); }
arrayBuffer() { return new ArrayBuffer(0); }
blob() { return new Blob(); }
formData() { return new FormData(); }
json() { return {}; }
text() { return ''; }
clone() {
return new MockNextResponse();
}
arrayBuffer() {
return new ArrayBuffer(0);
}
blob() {
return new Blob();
}
formData() {
return new FormData();
}
json() {
return {};
}
text() {
return "";
}
}
// Static methods
MockNextResponse.next = vi.fn(() => new MockNextResponse());
MockNextResponse.json = vi.fn();
MockNextResponse.json = vi.fn(
(_data, init) => new MockNextResponse(init),
) as typeof MockNextResponse.json;
MockNextResponse.redirect = vi.fn();
return {
...actual as Record<string, unknown>,
...(actual as Record<string, unknown>),
NextResponse: MockNextResponse,
}
};
});
describe('middleware', () => {
describe("middleware", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should bypass non-API requests', () => {
const request = new NextRequest('http://localhost:3000/test', {
headers: { origin: 'http://localhost:3000' },
it("should bypass non-API requests", () => {
const request = new NextRequest("http://localhost:3000/test", {
headers: { origin: "http://localhost:3000" },
});
const response = proxy(request);
expect(response).toBeDefined();
});
it('should handle preflight requests for API routes', () => {
const request = new NextRequest('http://localhost:3000/api/test', {
method: 'OPTIONS',
headers: { origin: 'http://localhost:3000' },
it("should handle preflight for allowed origin", () => {
const request = new NextRequest("http://localhost:3000/api/test", {
method: "OPTIONS",
headers: { origin: "http://localhost:3000" },
});
const response = proxy(request);
expect(response.status).toBe(200);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, PUT, DELETE, OPTIONS');
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type, Authorization');
expect(response.headers.get('Access-Control-Max-Age')).toBe('86400');
expect(response.headers.get('Vary')).toBe('Origin');
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
"http://localhost:3000",
);
expect(response.headers.get("Access-Control-Allow-Methods")).toBe(
"GET, POST, OPTIONS",
);
expect(response.headers.get("Access-Control-Allow-Headers")).toBe(
"Content-Type, Authorization",
);
expect(response.headers.get("Access-Control-Max-Age")).toBe("86400");
expect(response.headers.get("Vary")).toBe("Origin");
});
it('should handle regular API requests with cross-origin', () => {
const request = new NextRequest('http://localhost:3000/api/test', {
headers: { origin: 'http://different-origin.com' },
it("should reject preflight for disallowed origin", () => {
const request = new NextRequest("http://localhost:3000/api/test", {
method: "OPTIONS",
headers: { origin: "http://evil.example.com" },
});
const response = proxy(request);
expect(response.status).toBe(200);
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
expect(response.headers.get("Vary")).toBe("Origin");
});
it("should allow regular API request from allowed origin", () => {
const request = new NextRequest("http://localhost:3000/api/test", {
headers: { origin: "http://localhost:3000" },
});
const response = proxy(request);
expect(response).toBeDefined();
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Vary')).toBe('Origin');
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
"http://localhost:3000",
);
expect(response.headers.get("Vary")).toBe("Origin");
});
it('should handle API requests without Origin header', () => {
const request = new NextRequest('http://localhost:3000/api/test', {
it("should deny API request from disallowed origin", () => {
const request = new NextRequest("http://localhost:3000/api/test", {
headers: { origin: "http://different-origin.com" },
});
const response = proxy(request);
expect(response).toBeDefined();
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
});
it("should handle API requests without Origin header", () => {
const request = new NextRequest("http://localhost:3000/api/test", {
headers: {},
});
const response = proxy(request);
expect(response).toBeDefined();
// No CORS leak — should not echo *
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
});
it("should include rate-limit headers on allowed requests", () => {
const request = new NextRequest("http://localhost:3000/api/test", {
headers: {},
});
const response = proxy(request);
expect(response.headers.get("X-RateLimit-Limit")).toBeDefined();
expect(response.headers.get("X-RateLimit-Remaining")).toBeDefined();
});
});