From 364f4a82e37b60c4dbdf53035a8d06573cef5786 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Sun, 6 Sep 2026 07:09:10 +0900 Subject: [PATCH] Refuse a sandboxed component whose title is only whitespace --- CHANGELOG.md | 9 ++ server/src/components/sandboxed-routes.ts | 4 +- server/tests/sandboxed-routes.test.ts | 104 ++++++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 server/tests/sandboxed-routes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c658b496..e0e36a7cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A sandboxed component cannot be saved with a blank title + +`POST /api/sandboxed` accepted a title of spaces. The slug has a pattern of its own and refuses one, +but the title is a separate field with no check, so the component reached the grid with nothing to +read: an administrator could not tell it from the next one, and a Bot granted it was told the +component is called " ". The title is now trimmed, and one that is only whitespace is refused the +way a missing one always was. The two endpoints of the same shape, `POST /api/servers/custom` and +`POST /api/skills`, already did this. + ### The server connects to Postgres on Windows, and `localhost` is no longer a coin toss Two separate faults, both of which stop a deployment reaching its own database and neither of which diff --git a/server/src/components/sandboxed-routes.ts b/server/src/components/sandboxed-routes.ts index 8e4ae2263..2f6b3a733 100644 --- a/server/src/components/sandboxed-routes.ts +++ b/server/src/components/sandboxed-routes.ts @@ -63,14 +63,14 @@ export function createSandboxedRoutes( sampleArguments?: Record; } | null; - if (!body?.slug || !body.title) { + if (!body?.slug || !body.title?.trim()) { return context.json({ error: "A name and a title are required." }, 400); } try { const component = await store.save({ slug: body.slug, - title: body.title, + title: body.title.trim(), description: body.description ?? "", html: body.html ?? "", css: body.css ?? "", diff --git a/server/tests/sandboxed-routes.test.ts b/server/tests/sandboxed-routes.test.ts new file mode 100644 index 000000000..0069ff535 --- /dev/null +++ b/server/tests/sandboxed-routes.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; +import type { MiddlewareHandler } from "hono"; +import { Hono } from "hono"; +import type { AppVariables } from "../src/auth/guards"; +import { createSandboxedRoutes } from "../src/components/sandboxed-routes"; + +/** + * A component's title is what a person picks it out of the grid by, and what a Bot is told the + * component is called. A title of spaces leaves both with nothing to read, and the slug's own + * pattern check cannot catch it: the two are separate fields. + * + * The sibling endpoints already refuse one. `POST /api/servers/custom` and `POST /api/skills` both + * take the trimmed value or answer 400; this route was the third of that shape. + */ + +const ADMIN = { + id: "u1", + email: "admin@openbot.test", + role: "admin", +} as const; + +function app(role: "admin" | "user" = "admin") { + const saved: Array<{ slug: string; title: string }> = []; + + const store = { + list: async () => [], + published: async () => [], + save: async (input: { slug: string; title: string }) => { + saved.push({ slug: input.slug, title: input.title }); + return { name: input.slug, title: input.title }; + }, + } as never; + + const requireUser: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", { ...ADMIN, role }); + await next(); + }; + + return { + saved, + hono: new Hono().route( + "/api/sandboxed", + createSandboxedRoutes(store, requireUser), + ), + }; +} + +const post = (body: Record) => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); + +describe("saving a sandboxed component", () => { + test("refuses a title that is only whitespace", async () => { + const { saved, hono } = app(); + + const response = await hono.request( + "http://t/api/sandboxed", + post({ slug: "weather_card", title: " " }), + ); + + expect(response.status).toBe(400); + expect(saved).toHaveLength(0); + }); + + test("refuses a missing title, as it always did", async () => { + const { saved, hono } = app(); + + const response = await hono.request( + "http://t/api/sandboxed", + post({ slug: "weather_card" }), + ); + + expect(response.status).toBe(400); + expect(saved).toHaveLength(0); + }); + + test("stores a padded title without its padding", async () => { + const { saved, hono } = app(); + + const response = await hono.request( + "http://t/api/sandboxed", + post({ slug: "weather_card", title: " Weather card " }), + ); + + expect(response.status).toBe(200); + expect(saved).toEqual([{ slug: "weather_card", title: "Weather card" }]); + }); + + test("leaves an ordinary title alone", async () => { + const { saved, hono } = app(); + + await hono.request( + "http://t/api/sandboxed", + post({ slug: "weather_card", title: "Weather card" }), + ); + + expect(saved).toEqual([{ slug: "weather_card", title: "Weather card" }]); + }); +});