Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .agents/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
# Agent Instructions

## Git Workflow

When manipulating Git history and branches:

- Always rebase over merge commits when bringing a feature branch up to date with `main` (i.e. `git rebase main`).
- Always use a squash merge (or `squash and merge`) over regular merges when merging a feature branch into `main` (i.e. `git merge --squash feature-branch`). This keeps the commit history clean.

## Development Tasks

- Always use `mise` tasks instead of running package managers directly. Use `mise run <task>` (e.g., `mise run install`, `mise run format`, `mise run lint`, `mise run test`, `mise run verify`) to ensure correct tooling versions and dependencies are used.

## Tech Stack & Conventions

- **Framework**: Use Next.js with the App Router (`src/app`).
- **Styling**: Use Tailwind CSS for component styling.
- **Testing**: Use Vitest for unit and component testing. Place test files adjacent to their source files (e.g., `component.test.tsx`).
Expand Down
8 changes: 8 additions & 0 deletions firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
},
{
"key": "Cross-Origin-Opener-Policy",
"value": "same-origin"
},
{
"key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^26.0.0",
"postcss": "^8.5.15",
"postcss": "^8.5.23",
"prettier": "^3.8.4",
"tailwindcss": "^4.3.1",
"tsx": "^4.22.4",
Expand Down
71 changes: 47 additions & 24 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 63 additions & 0 deletions src/app/api/chat/ratelimit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,69 @@ describe("isRateLimited", () => {
expect(result).toBe(false);
});

it("should validate IPv6 address correctly from header", async () => {
mockLimit.mockResolvedValue({ success: true });

const req = new Request("http://localhost/api/chat", {
headers: { "x-real-ip": "2001:0db8:85a3:0000:0000:8a2e:0370:7334" },
});

await isRateLimited(req);
expect(mockLimit).toHaveBeenCalledWith(
"2001:0db8:85a3:0000:0000:8a2e:0370:7334",
);
});

it("should reject invalid IPv4 leading zeros or invalid IPv6 structures", async () => {
mockLimit.mockResolvedValue({ success: true });

// Test cases hitting various branch checks in IPv4 and IPv6 validators
const invalidIps = [
"01.2.3.4",
"2001::1::2",
"1:2:3",
"1:2:3:4:5:6:7:8:9",
"2001:xyz::1",
"",
];

for (const invalidIp of invalidIps) {
const req = new Request("http://localhost/api/chat", {
headers: { "x-real-ip": invalidIp },
});
await isRateLimited(req);
expect(mockLimit).toHaveBeenCalledWith("anonymous");
}
});

it("should fall back to anonymous when headers contain invalid IP strings", async () => {
mockLimit.mockResolvedValue({ success: true });

const req = new Request("http://localhost/api/chat", {
headers: {
"x-real-ip": "invalid-ip-address",
"x-forwarded-for": "999.999.999.999, not-an-ip",
},
});

await isRateLimited(req);
expect(mockLimit).toHaveBeenCalledWith("anonymous");
});

it("should extract valid IP if x-real-ip is invalid but x-forwarded-for contains a valid IP", async () => {
mockLimit.mockResolvedValue({ success: true });

const req = new Request("http://localhost/api/chat", {
headers: {
"x-real-ip": "malformed_ip",
"x-forwarded-for": "203.0.113.195, bad_ip",
},
});

await isRateLimited(req);
expect(mockLimit).toHaveBeenCalledWith("203.0.113.195");
});

it("should return true if rate limit is exceeded (success: false)", async () => {
mockLimit.mockResolvedValue({ success: false });

Expand Down
54 changes: 46 additions & 8 deletions src/app/api/chat/ratelimit.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,55 @@
import { ratelimit } from "@/lib/upstash";

export default async function isRateLimited(req: Request): Promise<boolean> {
let ip = req.headers.get("x-real-ip");
function isValidIPv4(ip: string): boolean {
const parts = ip.split(".");
if (parts.length !== 4) return false;
return parts.every((part) => {
if (!/^\d+$/.test(part)) return false;
if (part.length > 1 && part.startsWith("0")) return false;
const num = Number(part);
return num >= 0 && num <= 255;
});
}

function isValidIPv6(ip: string): boolean {
if (!ip || ip.length < 2) return false;
const doubleColonCount = (ip.match(/::/g) || []).length;
if (doubleColonCount > 1) return false;

const parts = ip.split("::");
const left = parts[0] ? parts[0].split(":") : [];
const right = parts.length > 1 && parts[1] ? parts[1].split(":") : [];

if (doubleColonCount === 0 && left.length !== 8) return false;
if (doubleColonCount === 1 && left.length + right.length >= 8) return false;

const allParts = [...left, ...right];
return allParts.every((part) => /^[0-9a-fA-F]{1,4}$/.test(part));
}

function isValidIP(ip: string): boolean {
return isValidIPv4(ip) || isValidIPv6(ip);
}

if (!ip) {
const forwardedFor = req.headers.get("x-forwarded-for");
if (forwardedFor) {
const parts = forwardedFor.split(",");
ip = parts[parts.length - 1].trim();
function extractCandidateIP(headerValue: string | null): string | null {
if (!headerValue) return null;
const parts = headerValue.split(",");
for (let i = parts.length - 1; i >= 0; i--) {
const candidate = parts[i].trim();
if (isValidIP(candidate)) {
return candidate;
}
}
return null;
}

export default async function isRateLimited(req: Request): Promise<boolean> {
// Extract and validate IP address from x-real-ip or x-forwarded-for headers to prevent spoofing/bypass
const ip =
extractCandidateIP(req.headers.get("x-real-ip")) ??
extractCandidateIP(req.headers.get("x-forwarded-for")) ??
"anonymous";

ip = ip ?? "anonymous";
const { success } = await ratelimit.limit(ip);
return !success;
}
Loading
Loading