-
Notifications
You must be signed in to change notification settings - Fork 74
Fix agent chat CORS and hydration mismatches #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
+21
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| 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); | ||
| }); | ||
| }); |
| 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; | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard pre-mount clicks to avoid incorrect first toggle behavior. On Line 36, 💡 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 |
||
| }} | ||
| > | ||
| <HugeiconsIcon icon={Sun03Icon} className={cn("!size-[1.1rem]", iconClassName)} /> | ||
| <span className="sr-only"> | ||
| {currentTheme === "dark" ? "Light" : "Dark"} | ||
| </span> | ||
| </Button> | ||
| ); | ||
| } | ||
| 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); | ||
| }); | ||
| }); |
| 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", | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical: arbitrary upstream proxy target enables SSRF/open-proxy abuse. On Line 48–Line 51, any absolute 🔒 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 |
||
| } | ||
|
|
||
| export async function streamChatCompletion({ | ||
| config, | ||
| messages, | ||
|
|
@@ -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", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export function isExternalHref(href: string) { | ||
| return /^https?:\/\//.test(href); | ||
| } |
This file was deleted.
| 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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Block SSRF/open-proxy behavior from untrusted
baseUrl.Line 8 accepts arbitrary
baseUrland Line 21 fetches it server-side. This allows proxying to internal/private endpoints unless constrained.🔐 Proposed mitigation (protocol + allowlist validation)
🤖 Prompt for AI Agents