Skip to content
Open
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
58 changes: 58 additions & 0 deletions apps/web/src/app/api/ai/agent/chat/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { type NextRequest, NextResponse } from "next/server";

function getUpstreamUrl(baseUrl: string) {
return `${baseUrl.replace(/\/+$/, "")}/chat/completions`;
}

export async function POST(request: NextRequest) {
const baseUrl = request.nextUrl.searchParams.get("baseUrl");

if (!baseUrl) {
return NextResponse.json(
{ error: "Missing baseUrl query parameter" },
{ status: 400 },
);
}

try {
const body = await request.text();
const authorization = request.headers.get("authorization");

const upstreamResponse = await fetch(getUpstreamUrl(baseUrl), {
method: "POST",
headers: {
"Content-Type": "application/json",
...(authorization ? { Authorization: authorization } : {}),
},
body,
});
Comment on lines +8 to +28
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Block SSRF/open-proxy behavior from untrusted baseUrl.

Line 8 accepts arbitrary baseUrl and Line 21 fetches it server-side. This allows proxying to internal/private endpoints unless constrained.

🔐 Proposed mitigation (protocol + allowlist validation)
+const ALLOWED_AGENT_CHAT_ORIGINS = new Set(
+	(process.env.AGENT_CHAT_ALLOWED_ORIGINS ?? "")
+		.split(",")
+		.map((v) => v.trim())
+		.filter(Boolean),
+);
+
 function getUpstreamUrl(baseUrl: string) {
-	return `${baseUrl.replace(/\/+$/, "")}/chat/completions`;
+	const parsed = new URL(baseUrl);
+	if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
+		throw new Error("Invalid protocol");
+	}
+	if (
+		ALLOWED_AGENT_CHAT_ORIGINS.size > 0 &&
+		!ALLOWED_AGENT_CHAT_ORIGINS.has(parsed.origin)
+	) {
+		throw new Error("Origin not allowed");
+	}
+	parsed.pathname = `${parsed.pathname.replace(/\/+$/, "")}/chat/completions`;
+	parsed.search = "";
+	parsed.hash = "";
+	return parsed.toString();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/api/ai/agent/chat/route.ts` around lines 8 - 28, The route
currently reads an unvalidated baseUrl and server-fetches it (baseUrl,
getUpstreamUrl, fetch), enabling SSRF/proxying; fix by validating baseUrl before
using it: ensure it uses an allowed protocol (e.g., http or https), parse it
with URL and reject non-absolute or non-http(s) values, then enforce an
allowlist (hostname or host pattern) and/or block private IP ranges
(RFC1918/localhost/169.254/::1) before calling getUpstreamUrl or fetch; if
validation fails, return a 400/403 JSON response and do not perform the upstream
fetch.

Comment on lines +21 to +28
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add an upstream timeout to prevent hung proxy requests.

The proxy call currently has no timeout; stalled upstream connections can hold server resources indefinitely.

⏱️ Proposed timeout guard
-		const upstreamResponse = await fetch(getUpstreamUrl(baseUrl), {
-			method: "POST",
-			headers: {
-				"Content-Type": "application/json",
-				...(authorization ? { Authorization: authorization } : {}),
-			},
-			body,
-		});
+		const controller = new AbortController();
+		const timeout = setTimeout(() => controller.abort(), 15_000);
+		const upstreamResponse = await fetch(getUpstreamUrl(baseUrl), {
+			method: "POST",
+			headers: {
+				"Content-Type": "application/json",
+				...(authorization ? { Authorization: authorization } : {}),
+			},
+			body,
+			signal: controller.signal,
+		});
+		clearTimeout(timeout);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const upstreamResponse = await fetch(getUpstreamUrl(baseUrl), {
method: "POST",
headers: {
"Content-Type": "application/json",
...(authorization ? { Authorization: authorization } : {}),
},
body,
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
const upstreamResponse = await fetch(getUpstreamUrl(baseUrl), {
method: "POST",
headers: {
"Content-Type": "application/json",
...(authorization ? { Authorization: authorization } : {}),
},
body,
signal: controller.signal,
});
clearTimeout(timeout);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/api/ai/agent/chat/route.ts` around lines 21 - 28, The fetch
to getUpstreamUrl in route.ts has no timeout and can hang; wrap the call with an
AbortController, pass controller.signal into the fetch options for the
upstreamRequest that produces upstreamResponse, and set a setTimeout (e.g., 30s)
to call controller.abort(); clear the timeout after fetch completes; also handle
the abort error path around the code that processes upstreamResponse (catch
AbortError and return a 504 or similar) so aborted requests free resources and
return a proper error.


if (!upstreamResponse.body) {
const errorText = await upstreamResponse.text();
return new NextResponse(errorText, {
status: upstreamResponse.status,
headers: {
"Content-Type":
upstreamResponse.headers.get("content-type") ?? "text/plain",
},
});
}

return new NextResponse(upstreamResponse.body, {
status: upstreamResponse.status,
headers: {
"Content-Type":
upstreamResponse.headers.get("content-type") ??
"text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
} catch (error) {
console.error("Agent chat proxy error:", error);
return NextResponse.json(
{ error: "Proxy request failed" },
{ status: 502 },
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { describe, expect, test } from "bun:test";
import { getFloatingParticles } from "../hero-particles";

describe("getFloatingParticles", () => {
test("returns stable particle coordinates across calls", () => {
expect(getFloatingParticles()).toEqual(getFloatingParticles());
expect(getFloatingParticles()).toHaveLength(6);
});
});
21 changes: 21 additions & 0 deletions apps/web/src/components/landing/hero-particles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type FloatingParticle = {
id: number;
size: number;
x: number;
y: number;
duration: number;
delay: number;
};

const FLOATING_PARTICLES: FloatingParticle[] = [
{ id: 0, size: 3.1, x: 14, y: 18, duration: 24, delay: -3 },
{ id: 1, size: 4.2, x: 27, y: 62, duration: 31, delay: -11 },
{ id: 2, size: 2.8, x: 43, y: 26, duration: 20, delay: -7 },
{ id: 3, size: 4.6, x: 61, y: 74, duration: 28, delay: -15 },
{ id: 4, size: 3.4, x: 78, y: 33, duration: 22, delay: -5 },
{ id: 5, size: 2.5, x: 89, y: 57, duration: 26, delay: -18 },
];

export function getFloatingParticles() {
return FLOATING_PARTICLES;
}
10 changes: 2 additions & 8 deletions apps/web/src/components/landing/hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,9 @@ import { Link } from "@/lib/navigation";
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
import { motion } from "motion/react";
import { useTranslation } from "@i18next-toolkit/nextjs-approuter";
import { getFloatingParticles } from "./hero-particles";

const floatingParticles = Array.from({ length: 6 }, (_, i) => ({
id: i,
size: 2 + Math.random() * 3,
x: 10 + Math.random() * 80,
y: 10 + Math.random() * 80,
duration: 15 + Math.random() * 20,
delay: Math.random() * -20,
}));
const floatingParticles = getFloatingParticles();

export function Hero() {
const { t } = useTranslation();
Expand Down
68 changes: 39 additions & 29 deletions apps/web/src/components/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,46 @@
"use client";

import { Button } from "./ui/button";
import { useTheme } from "next-themes";
import { cn } from "@/utils/ui";
import { Sun03Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
"use client";

import { Button } from "./ui/button";
import { useTheme } from "next-themes";
import { cn } from "@/utils/ui";
import { Sun03Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useState } from "react";

interface ThemeToggleProps {
className?: string;
iconClassName?: string;
onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}

export function ThemeToggle({
className,
iconClassName,
onToggle,
}: ThemeToggleProps) {
const { theme, setTheme } = useTheme();

return (
<Button
size="icon"
variant="ghost"
className={cn("size-8", className)}
onClick={(e) => {
setTheme(theme === "dark" ? "light" : "dark");
onToggle?.(e);
}}
>
<HugeiconsIcon icon={Sun03Icon} className={cn("!size-[1.1rem]", iconClassName)} />
<span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
</Button>
);
}
export function ThemeToggle({
className,
iconClassName,
onToggle,
}: ThemeToggleProps) {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);

useEffect(() => {
setMounted(true);
}, []);

const currentTheme = mounted ? resolvedTheme : undefined;

return (
<Button
size="icon"
variant="ghost"
className={cn("size-8", className)}
onClick={(e) => {
setTheme(currentTheme === "dark" ? "light" : "dark");
onToggle?.(e);
Comment on lines +35 to +37
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard pre-mount clicks to avoid incorrect first toggle behavior.

On Line 36, currentTheme is undefined before mount, so an early click always sets "dark". Consider disabling interaction until mounted.

💡 Suggested tweak
 		<Button
 			size="icon"
 			variant="ghost"
 			className={cn("size-8", className)}
+			disabled={!mounted}
 			onClick={(e) => {
 				setTheme(currentTheme === "dark" ? "light" : "dark");
 				onToggle?.(e);
 			}}
 		>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/theme-toggle.tsx` around lines 35 - 37, The
ThemeToggle click handler can run before mount because currentTheme is
undefined; update the ThemeToggle component to track mount state (e.g.,
isMounted via useEffect/useRef) and guard interactions: in the onClick handler
for the toggle (and/or set the control disabled or aria-disabled when not
mounted) return early if !isMounted or currentTheme == null/undefined so you
don't flip to "dark" by default; ensure you also skip calling onToggle when
gated so setTheme and onToggle are only invoked after mount.

}}
>
<HugeiconsIcon icon={Sun03Icon} className={cn("!size-[1.1rem]", iconClassName)} />
<span className="sr-only">
{currentTheme === "dark" ? "Light" : "Dark"}
</span>
</Button>
);
}
15 changes: 15 additions & 0 deletions apps/web/src/lib/__tests__/navigation-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, test } from "bun:test";
import { isExternalHref } from "../navigation-utils";

describe("isExternalHref", () => {
test("treats absolute http and https urls as external", () => {
expect(isExternalHref("https://github.com/msgbyte/cutia")).toBe(true);
expect(isExternalHref("http://example.com/docs")).toBe(true);
});

test("keeps local paths and hash links internal", () => {
expect(isExternalHref("/projects")).toBe(false);
expect(isExternalHref("#features")).toBe(false);
expect(isExternalHref("mailto:hello@example.com")).toBe(false);
});
});
24 changes: 24 additions & 0 deletions apps/web/src/lib/ai/agent/__tests__/llm-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import { getChatCompletionsUrl } from "../llm-client";

describe("getChatCompletionsUrl", () => {
test("uses same-origin proxy for external base urls", () => {
expect(
getChatCompletionsUrl({
baseUrl: "http://120.27.203.19:18080/v1",
}),
).toBe("/api/ai/agent/chat?baseUrl=http%3A%2F%2F120.27.203.19%3A18080%2Fv1");
});

test("keeps relative base urls on same origin", () => {
expect(getChatCompletionsUrl({ baseUrl: "/api/openai" })).toBe(
"/api/openai/chat/completions",
);
});

test("falls back to the default OpenAI base url through the proxy", () => {
expect(getChatCompletionsUrl({ baseUrl: "" })).toBe(
"/api/ai/agent/chat?baseUrl=https%3A%2F%2Fapi.openai.com%2Fv1",
);
});
});
31 changes: 26 additions & 5 deletions apps/web/src/lib/ai/agent/llm-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,31 @@ export interface ChatCompletionResult {
}>;
}

function normalizeBaseUrl(baseUrl: string) {
return (baseUrl || "https://api.openai.com/v1").replace(/\/+$/, "");
}

function isRelativeBaseUrl(baseUrl: string) {
return baseUrl.startsWith("/");
}

export function getChatCompletionsUrl({
baseUrl,
}: {
baseUrl: string;
}) {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);

if (isRelativeBaseUrl(normalizedBaseUrl)) {
return `${normalizedBaseUrl}/chat/completions`;
}

const params = new URLSearchParams({
baseUrl: normalizedBaseUrl,
});
return `/api/ai/agent/chat?${params.toString()}`;
Comment on lines +48 to +51
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: arbitrary upstream proxy target enables SSRF/open-proxy abuse.

On Line 48–Line 51, any absolute baseUrl is forwarded to the proxy query. With the current proxy route behavior (server-side fetch using that query param), this allows attacker-controlled upstream targets unless strict validation is enforced server-side.

🔒 Hardening direction
# In apps/web/src/app/api/ai/agent/chat/route.ts (server-side validation)
+const ALLOWED_HOSTS = new Set(["api.openai.com"]);
+
+function parseAndValidateBaseUrl(raw: string): URL | null {
+  try {
+    const u = new URL(raw);
+    if (u.protocol !== "https:") return null;
+    if (!ALLOWED_HOSTS.has(u.hostname)) return null;
+    return u;
+  } catch {
+    return null;
+  }
+}
+
-const baseUrl = request.nextUrl.searchParams.get("baseUrl");
+const rawBaseUrl = request.nextUrl.searchParams.get("baseUrl");
+if (!rawBaseUrl) {
+  return NextResponse.json({ error: "Missing baseUrl query parameter" }, { status: 400 });
+}
+const validated = parseAndValidateBaseUrl(rawBaseUrl);
+if (!validated) {
+  return NextResponse.json({ error: "Invalid baseUrl" }, { status: 400 });
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/lib/ai/agent/llm-client.ts` around lines 48 - 51, The code
builds a proxy query including an attacker-controlled absolute baseUrl
(normalizedBaseUrl) and returns `/api/ai/agent/chat?${params}`, enabling
SSRF/open-proxy; change this to never forward arbitrary absolute URLs from the
client and instead send either a validated hostname token or a canonical key:
validate normalizedBaseUrl against a server-side whitelist of allowed origins
(or convert it to a pre-approved lookup key) before adding it to
URLSearchParams, or drop absolute URLs entirely and only send relative/known
identifiers; ensure the server route `/api/ai/agent/chat` enforces the same
whitelist and rejects any requests with unrecognized normalizedBaseUrl values so
upstream targets cannot be attacker-controlled.

}

export async function streamChatCompletion({
config,
messages,
Expand All @@ -39,11 +64,7 @@ export async function streamChatCompletion({
callbacks: StreamCallbacks;
signal?: AbortSignal;
}): Promise<ChatCompletionResult> {
const baseUrl = (config.baseUrl || "https://api.openai.com/v1").replace(
/\/+$/,
"",
);
const url = `${baseUrl}/chat/completions`;
const url = getChatCompletionsUrl({ baseUrl: config.baseUrl });

const body: Record<string, unknown> = {
model: config.model || "gpt-4.1",
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/lib/navigation-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function isExternalHref(href: string) {
return /^https?:\/\//.test(href);
}
5 changes: 0 additions & 5 deletions apps/web/src/lib/navigation.ts

This file was deleted.

27 changes: 27 additions & 0 deletions apps/web/src/lib/navigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createNavigation } from "@i18next-toolkit/nextjs-approuter/navigation";
import {
forwardRef,
type ComponentPropsWithoutRef,
} from "react";
import { i18nConfig } from "../i18n.config";
import { isExternalHref } from "./navigation-utils";

const navigation = createNavigation(i18nConfig);
const BaseLink = navigation.Link;

type BaseLinkProps = ComponentPropsWithoutRef<typeof BaseLink>;
type LinkProps = BaseLinkProps & ComponentPropsWithoutRef<"a">;

export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
({ href, ...props }, ref) => {
if (typeof href === "string" && isExternalHref(href)) {
return <a ref={ref} href={href} {...props} />;
}

return <BaseLink ref={ref} href={href} {...props} />;
},
);

Link.displayName = "Link";

export const { redirect, usePathname, useRouter } = navigation;