Files
next-ai-draw-io/tests/unit/cross-site-requests.test.ts
T
dayuan.jiang 4731394f32 fix(security): check request sources, regions and endpoints
- Bedrock: a request's AWS region must be a region name. It becomes part
  of the endpoint's host name, so a value such as
  "us-east-1.attacker.example/" sent the server's bearer token or signed
  request to another host.
- MCP preview server: only the preview page itself (Origin equal to the
  Host) or a non-browser client may call it; a page on another localhost
  port could replace the diagram with a plain text POST. History builds
  its thumbnails element by element and shows only SVG data images, so a
  stored value can no longer run script in the preview.
- chat, validate-model, validate-diagram, provider-models and parse-url
  take JSON bodies only, so another website cannot make the user's own
  server (the desktop app, a local install) run models with their keys;
  the desktop app also refuses a foreign Host (DNS rebinding).
- The model list reads at most 2 MB, also through the Gateway SDK, and
  answers only with its own error texts: the URL is the caller's and may
  be an internal address.
- An admin panel provider with its own key and no URL no longer inherits
  the global <P>_BASE_URL, which may be a proxy for another key; OpenAI
  then gets the official endpoint, as its Test. Azure keeps the server's
  resource.
2026-10-05 17:02:06 +09:00

96 lines
3.2 KiB
TypeScript

// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { POST as chat } from "@/app/api/chat/route"
import { POST as parseUrl } from "@/app/api/parse-url/route"
import { POST as providerModels } from "@/app/api/provider-models/route"
import { POST as validateDiagram } from "@/app/api/validate-diagram/route"
import { POST as validateModel } from "@/app/api/validate-model/route"
// The routes that run models or fetch URLs, which a page on another site
// could otherwise make the user's own server do
const ROUTES = {
chat,
"parse-url": parseUrl,
"provider-models": providerModels,
"validate-diagram": validateDiagram,
"validate-model": validateModel,
}
const body = JSON.stringify({
messages: [
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
],
url: "https://example.com",
provider: "openai",
apiKey: "k",
modelId: "gpt-5.5",
imageData: "data:image/png;base64,AAAA",
})
const saved = process.env.NEXT_AI_DRAWIO_DESKTOP
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("no network in tests")
}),
)
})
afterEach(() => {
if (saved === undefined) delete process.env.NEXT_AI_DRAWIO_DESKTOP
else process.env.NEXT_AI_DRAWIO_DESKTOP = saved
vi.unstubAllGlobals()
})
describe("requests another website could send", () => {
for (const [name, post] of Object.entries(ROUTES)) {
it(`${name}: refuses a text body, which needs no CORS preflight`, async () => {
// fetch(..., { mode: "no-cors", body: JSON.stringify(...) })
// from another site arrives as text/plain
const res = await post(
new Request(`http://127.0.0.1:61337/api/${name}`, {
method: "POST",
body,
}),
)
expect(res.status).toBe(415)
expect(fetch).not.toHaveBeenCalled()
})
it(`${name}: desktop app refuses a foreign Host (DNS rebinding)`, async () => {
process.env.NEXT_AI_DRAWIO_DESKTOP = "1"
const res = await post(
new Request(`http://127.0.0.1:61337/api/${name}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
host: "rebind.attacker.example:61337",
},
body,
}),
)
expect(res.status).toBe(403)
expect(fetch).not.toHaveBeenCalled()
})
}
it("lets the desktop window's own requests through", async () => {
process.env.NEXT_AI_DRAWIO_DESKTOP = "1"
const res = await validateModel(
new Request("http://127.0.0.1:61337/api/validate-model", {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
host: "127.0.0.1:61337",
},
body: JSON.stringify({ provider: "openai" }),
}),
)
// Past the check: the route's own validation answers
expect(res.status).toBe(400)
expect(await res.text()).toMatch(/required/)
})
})