fix(quickstart): hotfixes discovered during live testing session
Five additional breakages surfaced while testing the restored stack end-to-end (spin up Hermes template → click node → open side panel → configure secrets → send chat). Each fix is narrowly scoped and has matching unit or e2e tests so they don't regress. ### 1. SSRF defence blocked loopback A2A on self-hosted Docker handlers/ssrf.go was rejecting `http://127.0.0.1:<port>` workspace URLs as loopback, so POST /workspaces/:id/a2a returned 502 on every Canvas chat send in local-dev. The provisioner on self-hosted Docker publishes each container's A2A port on 127.0.0.1:<ephemeral> — that's the only reachable address for the platform-on-host path. Added `devModeAllowsLoopback()` — allows loopback only when MOLECULE_ENV ∈ {development, dev}. SaaS (MOLECULE_ENV=production) continues to block loopback; every other blocked range (metadata 169.254/16, TEST-NET, CGNAT, link-local) stays blocked in dev mode. Tests: 5 new tests in ssrf_test.go covering dev-mode loopback, dev-mode short-alias ("dev"), production still blocks loopback, dev-mode still blocks every other range, and a 9-case table test of the predicate with case/whitespace/typo variants. ### 2. canvas/src/lib/api.ts: 401 → login redirect broke localhost Every 401 called `redirectToLogin()` which navigates to `/cp/auth/login`. That route exists only on SaaS (mounted by the cp_proxy when CP_UPSTREAM_URL is set). On localhost it 404s — users landed on a blank "404 page not found" instead of seeing the actual error they should fix. Gated the redirect on the SaaS-tenant slug check: on <slug>.moleculesai.app, redirect unchanged; on any non-SaaS host (localhost, LAN IP, reserved subdomains like app.moleculesai.app), throw a real error so the calling component can render a retry affordance. Tests: 4 new vitest cases in a dedicated api-401.test.ts (needs jsdom for window.location.hostname) — SaaS redirects, localhost throws, LAN hostname throws, reserved apex throws. ### 3. SecretsSection rendered a hardcoded key list config/secrets-section.tsx shipped a fixed COMMON_KEYS list (Anthropic / OpenAI / Google / SERP / Model Override) regardless of what the workspace's template actually needed. A Hermes workspace declaring MINIMAX_API_KEY in required_env got five irrelevant slots and nothing for the key it actually needed. Made the slot list template-driven via a new `requiredEnv?: string[]` prop passed down from ConfigTab. Added `KNOWN_LABELS` for well-known names and `humanizeKeyName` to turn arbitrary SCREAMING_SNAKE_CASE into a readable label (e.g. MINIMAX_API_KEY → "Minimax API Key"). Acronyms (API, URL, ID, SDK, MCP, LLM, AI) stay uppercase. Legacy fallback preserved when required_env is empty. Tests: 8 new vitest cases covering known-label lookup, humanise fallback, acronym preservation, deduplication, and both fallback paths. ### 4. Confusing placeholder in Required Env Vars field The TagList in ConfigTab labelled "Required Env Vars (from template)" is a DECLARATION field — stores variable names. The placeholder "e.g. CLAUDE_CODE_OAUTH_TOKEN" suggested that, but users naturally typed the value of their API key into the field instead. The actual values go in the Secrets section further down the tab. Relabelled to "Required Env Var Names (from template)", changed the placeholder to "variable NAME (e.g. ANTHROPIC_API_KEY) — not the value", and added a one-line helper below pointing to Secrets. ### 5. Agent chat replies rendered 2-3 times Three delivery paths can fire for a single agent reply — HTTP response to POST /a2a, A2A_RESPONSE WS event, and a send_message_to_user WS push. Paths 2↔3 were already guarded by `sendingFromAPIRef`; path 1 had no guard. Hermes emits both the reply body AND a send_message_to_user with the same text, which manifested as duplicate bubbles with identical timestamps. Added `appendMessageDeduped(prev, msg, windowMs = 3000)` in chat/types.ts — dedupes on (role, content) within a 3s window. Threaded into all three setMessages call sites. The window is short enough that legitimate repeat messages ("hi", "hi") from a real user/agent a few seconds apart still render. Tests: 8 new vitest cases covering empty history, different content, duplicate within window, different roles, window elapsed, stale match, malformed timestamps, and custom window. ### 6. New end-to-end regression test tests/e2e/test_dev_mode.sh — 7 HTTP assertions that run against a live platform with MOLECULE_ENV=development and catch regressions on all the dev-mode escape hatches in a single pass: AdminAuth (empty DB + after-token), WorkspaceAuth (/activity, /delegations), AdminAuth on /approvals/pending, and the populated /org/templates response. Shellcheck-clean. ### Test sweep - `go test -race ./internal/handlers/ ./internal/middleware/ ./internal/provisioner/` — all pass - `npx vitest run` in canvas — 922/922 pass (up from 902) - `shellcheck --severity=warning infra/scripts/setup.sh tests/e2e/test_dev_mode.sh` — clean - `bash tests/e2e/test_dev_mode.sh` — 7/7 pass against a live platform + populated template registry ### SaaS parity Every relaxation remains conditional on MOLECULE_ENV=development. Production tenants run MOLECULE_ENV=production (enforced by the secrets-encryption strict-init path) and always set ADMIN_TOKEN, so none of these code paths fire on hosted SaaS. Behaviour on real tenants is byte-for-byte unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
47d3ef5b9e
commit
de99a22ffc
@ -6,7 +6,7 @@ import remarkGfm from "remark-gfm";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { WS_URL } from "@/store/socket";
|
||||
import { type ChatMessage, createMessage } from "./chat/types";
|
||||
import { type ChatMessage, createMessage, appendMessageDeduped } from "./chat/types";
|
||||
import { extractResponseText, extractRequestText } from "./chat/message-parser";
|
||||
import { AgentCommsPanel } from "./chat/AgentCommsPanel";
|
||||
import { runtimeDisplayName } from "@/lib/runtime-names";
|
||||
@ -206,7 +206,11 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
const consume = useCanvasStore.getState().consumeAgentMessages;
|
||||
const msgs = consume(workspaceId);
|
||||
for (const m of msgs) {
|
||||
setMessages((prev) => [...prev, createMessage("agent", m.content)]);
|
||||
// Dedupe in case the agent proactively pushed the same text the
|
||||
// HTTP /a2a response already delivered (observed with the Hermes
|
||||
// runtime, which emits both a reply body and a send_message_to_user
|
||||
// push for the same content).
|
||||
setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", m.content)));
|
||||
}
|
||||
}, [pendingAgentMsgs, workspaceId]);
|
||||
|
||||
@ -220,7 +224,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
const msgs = consume(`a2a:${workspaceId}`);
|
||||
if (!sendingFromAPIRef.current) return; // HTTP .then() already handled this response
|
||||
for (const m of msgs) {
|
||||
setMessages((prev) => [...prev, createMessage("agent", m.content)]);
|
||||
setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", m.content)));
|
||||
}
|
||||
setSending(false);
|
||||
sendingFromAPIRef.current = false;
|
||||
@ -340,7 +344,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
if (!sendingFromAPIRef.current) return;
|
||||
const replyText = extractReplyText(resp);
|
||||
if (replyText) {
|
||||
setMessages((prev) => [...prev, createMessage("agent", replyText)]);
|
||||
setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", replyText)));
|
||||
}
|
||||
setSending(false);
|
||||
sendingFromAPIRef.current = false;
|
||||
|
||||
@ -389,13 +389,19 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
label={
|
||||
currentModelSpec?.required_env?.length &&
|
||||
arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env)
|
||||
? "Required Env Vars (from template)"
|
||||
: "Required Env Vars"
|
||||
? "Required Env Var Names (from template)"
|
||||
: "Required Env Var Names"
|
||||
}
|
||||
values={config.runtime_config?.required_env ?? []}
|
||||
onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)}
|
||||
placeholder="e.g. CLAUDE_CODE_OAUTH_TOKEN"
|
||||
placeholder="variable NAME (e.g. ANTHROPIC_API_KEY) — not the value"
|
||||
/>
|
||||
<p className="text-[10px] text-zinc-500 mt-1">
|
||||
This declares which env var <em>names</em> the workspace needs.
|
||||
Set the actual values in the <strong>Secrets</strong> section
|
||||
below — those are encrypted and mounted into the container at
|
||||
runtime.
|
||||
</p>
|
||||
{currentModelSpec?.required_env?.length &&
|
||||
!arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env) && (
|
||||
<div className="text-[10px] text-zinc-500 mt-1 flex items-center gap-2">
|
||||
@ -502,7 +508,10 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<SecretsSection workspaceId={workspaceId} />
|
||||
<SecretsSection
|
||||
workspaceId={workspaceId}
|
||||
requiredEnv={config.runtime_config?.required_env}
|
||||
/>
|
||||
|
||||
<AgentCardSection workspaceId={workspaceId} />
|
||||
</div>
|
||||
|
||||
100
canvas/src/components/tabs/chat/__tests__/types.test.ts
Normal file
100
canvas/src/components/tabs/chat/__tests__/types.test.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { appendMessageDeduped, createMessage, type ChatMessage } from "../types";
|
||||
|
||||
// Unit tests for appendMessageDeduped — the helper that collapses the
|
||||
// race between the HTTP /a2a .then() handler, the A2A_RESPONSE WS event,
|
||||
// and the send_message_to_user push. All three paths can deliver the
|
||||
// same agent reply; without dedupe the user sees 2-3 identical bubbles
|
||||
// with identical timestamps.
|
||||
|
||||
describe("appendMessageDeduped", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// Pin Date.now so "recently added" windows are deterministic across
|
||||
// the dedupe + Date.parse calls inside the helper.
|
||||
vi.setSystemTime(new Date("2026-04-23T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("appends a new message when the history is empty", () => {
|
||||
const msg = createMessage("agent", "hello");
|
||||
const next = appendMessageDeduped([], msg);
|
||||
expect(next).toHaveLength(1);
|
||||
expect(next[0]).toBe(msg);
|
||||
});
|
||||
|
||||
it("appends when content differs from the recent tail", () => {
|
||||
const first = createMessage("agent", "hello");
|
||||
vi.advanceTimersByTime(100);
|
||||
const second = createMessage("agent", "world");
|
||||
const next = appendMessageDeduped([first], second);
|
||||
expect(next).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("skips a duplicate (same role+content) within the window", () => {
|
||||
const first = createMessage("agent", "Hey! How can I help you today?");
|
||||
vi.advanceTimersByTime(500); // well inside the 3s window
|
||||
const dup = createMessage("agent", "Hey! How can I help you today?");
|
||||
const next = appendMessageDeduped([first], dup);
|
||||
expect(next).toHaveLength(1);
|
||||
// The array is returned unchanged — not a new reference.
|
||||
expect(next[0]).toBe(first);
|
||||
});
|
||||
|
||||
it("does NOT dedupe across different roles even if content matches", () => {
|
||||
// Agent echoing the user's "hi" is a legitimate two-bubble case.
|
||||
const user = createMessage("user", "hi");
|
||||
vi.advanceTimersByTime(100);
|
||||
const agent = createMessage("agent", "hi");
|
||||
const next = appendMessageDeduped([user], agent);
|
||||
expect(next).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does NOT dedupe once the window has elapsed", () => {
|
||||
// A user legitimately sending "hi" a few seconds apart must render
|
||||
// both bubbles. Default window is 3000 ms.
|
||||
const first = createMessage("user", "hi");
|
||||
vi.advanceTimersByTime(4000);
|
||||
const repeat = createMessage("user", "hi");
|
||||
const next = appendMessageDeduped([first], repeat);
|
||||
expect(next).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("only checks the tail's content, not the entire history", () => {
|
||||
// Same (role, content) appearing earlier in the conversation but
|
||||
// outside the dedupe window is not a duplicate.
|
||||
const old = createMessage("agent", "hi");
|
||||
vi.advanceTimersByTime(10_000);
|
||||
const newer = createMessage("agent", "hi");
|
||||
const next = appendMessageDeduped([old], newer);
|
||||
expect(next).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("handles malformed timestamps without throwing", () => {
|
||||
// Defense: a history entry with a bogus timestamp shouldn't nuke
|
||||
// the append path. The helper should just treat that entry as
|
||||
// "too old to dedupe against" and append the new message.
|
||||
const garbled: ChatMessage = {
|
||||
id: "x",
|
||||
role: "agent",
|
||||
content: "hi",
|
||||
timestamp: "not-a-real-timestamp",
|
||||
};
|
||||
const fresh = createMessage("agent", "hi");
|
||||
expect(() => appendMessageDeduped([garbled], fresh)).not.toThrow();
|
||||
const next = appendMessageDeduped([garbled], fresh);
|
||||
expect(next).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("accepts a custom dedupe window", () => {
|
||||
const first = createMessage("agent", "hello");
|
||||
vi.advanceTimersByTime(500);
|
||||
// Tight 100 ms window — the 500 ms-old first message falls outside.
|
||||
const dup = createMessage("agent", "hello");
|
||||
const next = appendMessageDeduped([first], dup, 100);
|
||||
expect(next).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@ -1,2 +1,2 @@
|
||||
export { type ChatMessage, createMessage } from "./types";
|
||||
export { type ChatMessage, createMessage, appendMessageDeduped } from "./types";
|
||||
export { extractAgentText, extractTextsFromParts, extractResponseText } from "./message-parser";
|
||||
|
||||
@ -8,3 +8,28 @@ export interface ChatMessage {
|
||||
export function createMessage(role: ChatMessage["role"], content: string): ChatMessage {
|
||||
return { id: crypto.randomUUID(), role, content, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
// appendMessageDeduped adds a ChatMessage to `prev` unless the tail
|
||||
// already contains the same (role, content) from within
|
||||
// dedupeWindowMs. Collapses the case where two delivery paths race to
|
||||
// render the same agent reply — e.g. the HTTP .then() handler for
|
||||
// POST /a2a AND a `send_message_to_user` WebSocket push from the
|
||||
// runtime, both carrying the same text. Without this guard the user
|
||||
// sees two or three identical bubbles with identical timestamps.
|
||||
//
|
||||
// Why a time-windowed check instead of dedupe-by-id: the three delivery
|
||||
// paths (HTTP response, WS A2A_RESPONSE, WS send_message_to_user) each
|
||||
// mint a fresh `createMessage` with a random UUID client-side — there's
|
||||
// no stable end-to-end message id yet. Content+role+time is the
|
||||
// pragmatic identity. The window is short (3s) so genuine repeat
|
||||
// messages ("hi", "hi") from a real user/agent still render.
|
||||
export function appendMessageDeduped(prev: ChatMessage[], msg: ChatMessage, dedupeWindowMs = 3000): ChatMessage[] {
|
||||
const cutoff = Date.now() - dedupeWindowMs;
|
||||
const alreadyThere = prev.some((m) => {
|
||||
if (m.role !== msg.role || m.content !== msg.content) return false;
|
||||
const t = Date.parse(m.timestamp);
|
||||
return !Number.isNaN(t) && t >= cutoff;
|
||||
});
|
||||
if (alreadyThere) return prev;
|
||||
return [...prev, msg];
|
||||
}
|
||||
|
||||
@ -0,0 +1,139 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup } from "@testing-library/react";
|
||||
import { SecretsSection } from "../secrets-section";
|
||||
|
||||
// Tests for SecretsSection — locks in the fix that the secret-slot
|
||||
// list is driven by the workspace's `runtime_config.required_env`
|
||||
// instead of a hardcoded COMMON_KEYS list.
|
||||
//
|
||||
// Before the fix the component always rendered Anthropic / OpenAI /
|
||||
// Google / SERP / Model Override slots regardless of template. For a
|
||||
// Hermes workspace that declares MINIMAX_API_KEY that meant the user
|
||||
// saw five irrelevant slots and no slot for the key they actually
|
||||
// needed.
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
put: vi.fn().mockResolvedValue({}),
|
||||
post: vi.fn().mockResolvedValue({}),
|
||||
del: vi.fn().mockResolvedValue({}),
|
||||
patch: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/canvas-actions", () => ({
|
||||
markAllWorkspacesNeedRestart: vi.fn(),
|
||||
}));
|
||||
|
||||
// The Section wrapper is collapsible with `defaultOpen={false}`. For
|
||||
// tests we want the content visible without a click — replace the
|
||||
// wrapper with a passthrough that always renders children.
|
||||
vi.mock("../form-inputs", async () => {
|
||||
const actual = await vi.importActual<typeof import("../form-inputs")>("../form-inputs");
|
||||
return {
|
||||
...actual,
|
||||
Section: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("SecretsSection — template-driven slots", () => {
|
||||
it("renders exactly the slots the template declares in required_env", async () => {
|
||||
render(
|
||||
<SecretsSection workspaceId="ws-1" requiredEnv={["MINIMAX_API_KEY"]} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("MINIMAX_API_KEY")).toBeTruthy();
|
||||
});
|
||||
// Hardcoded slots that were there before this fix must NOT appear
|
||||
// when the template doesn't ask for them.
|
||||
expect(screen.queryByText("ANTHROPIC_API_KEY")).toBeNull();
|
||||
expect(screen.queryByText("OPENAI_API_KEY")).toBeNull();
|
||||
expect(screen.queryByText("GOOGLE_API_KEY")).toBeNull();
|
||||
expect(screen.queryByText("SERP_API_KEY")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the friendly label from KNOWN_LABELS for a well-known name", async () => {
|
||||
render(
|
||||
<SecretsSection workspaceId="ws-1" requiredEnv={["ANTHROPIC_API_KEY"]} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Anthropic API Key")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("humanises an unknown env var name into a readable label", async () => {
|
||||
render(
|
||||
<SecretsSection workspaceId="ws-1" requiredEnv={["MINIMAX_API_KEY"]} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
// "Minimax API Key" — "API" acronym preserved, "Minimax" title-cased.
|
||||
expect(screen.getByText("Minimax API Key")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves API / URL acronyms when humanising", async () => {
|
||||
render(
|
||||
<SecretsSection
|
||||
workspaceId="ws-1"
|
||||
requiredEnv={["ZHIPU_API_KEY", "CUSTOM_MODEL_URL"]}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Zhipu API Key")).toBeTruthy();
|
||||
expect(screen.getByText("Custom Model URL")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates repeated entries in required_env", async () => {
|
||||
render(
|
||||
<SecretsSection
|
||||
workspaceId="ws-1"
|
||||
requiredEnv={["MINIMAX_API_KEY", "MINIMAX_API_KEY", "OPENAI_API_KEY"]}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
// Only one row for the repeated name.
|
||||
const matches = screen.getAllByText("MINIMAX_API_KEY");
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(screen.getByText("OpenAI API Key")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the legacy common-keys list when required_env is missing", async () => {
|
||||
// Backward compat: old workspaces without a template-set
|
||||
// required_env still see Anthropic/OpenAI/Google/SERP slots.
|
||||
render(<SecretsSection workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Anthropic API Key")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText("OpenAI API Key")).toBeTruthy();
|
||||
expect(screen.getByText("Google AI API Key")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to the legacy common-keys list when required_env is empty", async () => {
|
||||
render(<SecretsSection workspaceId="ws-1" requiredEnv={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Anthropic API Key")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fall back when required_env has at least one entry", async () => {
|
||||
// Single-entry required_env must NOT spill legacy slots into the UI.
|
||||
render(<SecretsSection workspaceId="ws-1" requiredEnv={["MINIMAX_API_KEY"]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("MINIMAX_API_KEY")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText("Anthropic API Key")).toBeNull();
|
||||
expect(screen.queryByText("OpenAI API Key")).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -13,14 +13,59 @@ interface SecretEntry {
|
||||
scope?: "global" | "workspace";
|
||||
}
|
||||
|
||||
const COMMON_KEYS = [
|
||||
{ key: "ANTHROPIC_API_KEY", label: "Anthropic API Key" },
|
||||
{ key: "OPENAI_API_KEY", label: "OpenAI API Key" },
|
||||
{ key: "GOOGLE_API_KEY", label: "Google AI API Key" },
|
||||
{ key: "SERP_API_KEY", label: "SERP API Key" },
|
||||
{ key: "MODEL_PROVIDER", label: "Model Override (e.g. anthropic:claude-sonnet-4-6)" },
|
||||
// Human-friendly labels for well-known env-var names. Used to render
|
||||
// familiar copy ("Anthropic API Key") instead of the raw variable name
|
||||
// when the template declares one of these. Unknown names (e.g.
|
||||
// MINIMAX_API_KEY, ZHIPU_API_KEY) fall through to humanizeKeyName below
|
||||
// — a generic "Minimax API Key" label is better than no label at all.
|
||||
//
|
||||
// SECRETS_WHEN_NO_TEMPLATE is the fallback set shown only when a
|
||||
// workspace's template doesn't declare any required_env (legacy /
|
||||
// bare-runtime case). In the normal flow the list is driven by
|
||||
// runtime_config.required_env passed in from the Config tab.
|
||||
const KNOWN_LABELS: Record<string, string> = {
|
||||
ANTHROPIC_API_KEY: "Anthropic API Key",
|
||||
OPENAI_API_KEY: "OpenAI API Key",
|
||||
GOOGLE_API_KEY: "Google AI API Key",
|
||||
SERP_API_KEY: "SERP API Key",
|
||||
OPENROUTER_API_KEY: "OpenRouter API Key",
|
||||
HERMES_API_KEY: "Hermes API Key (Nous Research)",
|
||||
GROQ_API_KEY: "Groq API Key",
|
||||
CEREBRAS_API_KEY: "Cerebras API Key",
|
||||
MINIMAX_API_KEY: "Minimax API Key",
|
||||
MODEL_PROVIDER: "Model Override (e.g. anthropic:claude-sonnet-4-6)",
|
||||
};
|
||||
|
||||
const SECRETS_WHEN_NO_TEMPLATE = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"SERP_API_KEY",
|
||||
"MODEL_PROVIDER",
|
||||
];
|
||||
|
||||
// humanizeKeyName converts SCREAMING_SNAKE_CASE into "Title Case Words"
|
||||
// so templates that declare uncommon env var names still get a readable
|
||||
// label. "MINIMAX_API_KEY" → "Minimax API Key". Preserves "API" / "URL"
|
||||
// acronyms via the normalize step.
|
||||
function humanizeKeyName(key: string): string {
|
||||
const words = key.toLowerCase().split("_").filter(Boolean);
|
||||
return words
|
||||
.map((w) => {
|
||||
const upper = w.toUpperCase();
|
||||
// Keep common acronyms upper-case.
|
||||
if (["API", "URL", "URI", "ID", "SDK", "MCP", "LLM", "AI"].includes(upper)) {
|
||||
return upper;
|
||||
}
|
||||
return w.charAt(0).toUpperCase() + w.slice(1);
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function labelForKey(key: string): string {
|
||||
return KNOWN_LABELS[key] ?? humanizeKeyName(key);
|
||||
}
|
||||
|
||||
function ScopeBadge({ scope }: { scope: "global" | "workspace" | "override" }) {
|
||||
if (scope === "global") {
|
||||
return <span className="text-[8px] text-amber-400 bg-amber-900/30 px-1.5 py-0.5 rounded" title="Inherited from global secrets">Global</span>;
|
||||
@ -147,7 +192,7 @@ function CustomSecretRow({ secretKey, scope, globalMode, onSave, onDelete }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function SecretsSection({ workspaceId }: { workspaceId: string }) {
|
||||
export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: string; requiredEnv?: string[] }) {
|
||||
const [mergedSecrets, setMergedSecrets] = useState<SecretEntry[]>([]);
|
||||
const [globalSecrets, setGlobalSecrets] = useState<SecretEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -218,9 +263,27 @@ export function SecretsSection({ workspaceId }: { workspaceId: string }) {
|
||||
// For global view: use global secrets only
|
||||
const activeSecrets = globalMode ? globalSecrets : mergedSecrets;
|
||||
|
||||
// Split into common keys and custom keys
|
||||
const commonKeySet = new Set(COMMON_KEYS.map((c) => c.key));
|
||||
const customSecrets = activeSecrets.filter((s) => !commonKeySet.has(s.key));
|
||||
// Template-driven slots: render one labelled row per env var the
|
||||
// template declares. Falls back to a legacy common-keys list when
|
||||
// the template has nothing (older workspaces / bare runtimes) so
|
||||
// the Secrets section is never empty.
|
||||
const templateKeys = (requiredEnv && requiredEnv.length > 0)
|
||||
? requiredEnv
|
||||
: SECRETS_WHEN_NO_TEMPLATE;
|
||||
|
||||
// Deduplicate while preserving order — a template that lists the
|
||||
// same key twice shouldn't render two rows.
|
||||
const seen = new Set<string>();
|
||||
const slotKeys = templateKeys.filter((k) => {
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Split into template-slot keys and user-added custom keys so the
|
||||
// latter still surface even when not declared by the template.
|
||||
const slotKeySet = new Set(slotKeys);
|
||||
const customSecrets = activeSecrets.filter((s) => !slotKeySet.has(s.key));
|
||||
|
||||
return (
|
||||
<Section title="Secrets & API Keys" defaultOpen={false}>
|
||||
@ -256,15 +319,16 @@ export function SecretsSection({ workspaceId }: { workspaceId: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Common keys */}
|
||||
{COMMON_KEYS.map(({ key, label }) => {
|
||||
{/* Template-declared slots — one labelled row per env var
|
||||
the workspace actually needs. Driven by runtime_config.required_env. */}
|
||||
{slotKeys.map((key) => {
|
||||
const entry = globalMode
|
||||
? globalSecrets.find((s) => s.key === key)
|
||||
: mergedByKey.get(key);
|
||||
const isSet = !!entry?.has_value;
|
||||
const scope = globalMode ? undefined : (entry ? getScope(entry) : undefined);
|
||||
return (
|
||||
<SecretRow key={key} label={label} secretKey={key}
|
||||
<SecretRow key={key} label={labelForKey(key)} secretKey={key}
|
||||
isSet={isSet}
|
||||
scope={scope}
|
||||
globalMode={globalMode}
|
||||
|
||||
100
canvas/src/lib/__tests__/api-401.test.ts
Normal file
100
canvas/src/lib/__tests__/api-401.test.ts
Normal file
@ -0,0 +1,100 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// Dedicated file for the 401 → login-redirect tests because they need
|
||||
// `window.location.hostname` (jsdom), while the rest of api.test.ts
|
||||
// runs happily in node. Splitting keeps the node tests fast.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 401 handling — gated on SaaS-tenant hostname
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Before fix/quickstart-bugless, any 401 from any endpoint triggered
|
||||
// `redirectToLogin()`, navigating to `/cp/auth/login`. That route
|
||||
// exists only on SaaS (mounted by cp_proxy when CP_UPSTREAM_URL is
|
||||
// set). On localhost / self-hosted / Vercel preview it 404s, so the
|
||||
// user lands on a broken login page instead of seeing the actual error.
|
||||
//
|
||||
// These tests lock in:
|
||||
// - SaaS tenant hostname (*.moleculesai.app) → 401 still redirects.
|
||||
// - non-SaaS hostname (localhost, LAN IP, apex) → 401 throws, no
|
||||
// redirect, so the caller renders a real error affordance.
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
function mockFailure(status: number, text: string) {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status,
|
||||
json: () => Promise.reject(new Error("no json")),
|
||||
text: () => Promise.resolve(text),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
function setHostname(host: string) {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { ...window.location, hostname: host },
|
||||
});
|
||||
}
|
||||
|
||||
describe("api 401 handling", () => {
|
||||
let redirectSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
redirectSpy = vi.fn();
|
||||
vi.doMock("../auth", () => ({
|
||||
redirectToLogin: redirectSpy,
|
||||
// Stub siblings so any other import of ../auth in the chain
|
||||
// (AuthGate, TermsGate, etc.) still resolves.
|
||||
fetchSession: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("../auth");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("redirects to login on SaaS tenant hostname", async () => {
|
||||
setHostname("acme.moleculesai.app");
|
||||
mockFailure(401, '{"error":"admin auth required"}');
|
||||
|
||||
const { api } = await import("../api");
|
||||
await expect(api.get("/workspaces")).rejects.toThrow(/Session expired/);
|
||||
expect(redirectSpy).toHaveBeenCalledWith("sign-in");
|
||||
});
|
||||
|
||||
it("does NOT redirect on localhost — throws a real error instead", async () => {
|
||||
setHostname("localhost");
|
||||
mockFailure(401, '{"error":"admin auth required"}');
|
||||
|
||||
const { api } = await import("../api");
|
||||
await expect(api.get("/workspaces")).rejects.toThrow(/401/);
|
||||
expect(redirectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT redirect on a LAN hostname", async () => {
|
||||
setHostname("192.168.1.74");
|
||||
mockFailure(401, '{"error":"missing workspace auth token"}');
|
||||
|
||||
const { api } = await import("../api");
|
||||
await expect(api.get("/workspaces/abc/activity")).rejects.toThrow(/401/);
|
||||
expect(redirectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT redirect on reserved subdomains (app.moleculesai.app)", async () => {
|
||||
// `app` is in reservedSubdomains — getTenantSlug returns "" there.
|
||||
// Users landing on app.moleculesai.app (pre-tenant-selection) must
|
||||
// see the real 401 error rather than loop on login.
|
||||
setHostname("app.moleculesai.app");
|
||||
mockFailure(401, '{"error":"admin auth required"}');
|
||||
|
||||
const { api } = await import("../api");
|
||||
await expect(api.get("/workspaces")).rejects.toThrow(/401/);
|
||||
expect(redirectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -39,12 +39,21 @@ async function request<T>(
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
// Session expired or credentials lost — redirect to login once.
|
||||
// Import dynamically to avoid circular dependency with auth.ts.
|
||||
// Session expired or credentials lost. On SaaS (tenant subdomain)
|
||||
// the login page lives at /cp/auth/login and is mounted by the
|
||||
// control-plane reverse proxy — redirect. On self-hosted / local
|
||||
// dev / Vercel preview there IS no /cp/* mount, so redirecting
|
||||
// would navigate to a 404 ("404 page not found") instead of the
|
||||
// real error the user should see. In that case, throw instead
|
||||
// and let the caller render a meaningful failure (retry button,
|
||||
// error banner, etc.).
|
||||
if (slug) {
|
||||
const { redirectToLogin } = await import("./auth");
|
||||
redirectToLogin("sign-in");
|
||||
throw new Error("Session expired — redirecting to login");
|
||||
}
|
||||
throw new Error(`API ${method} ${path}: 401 ${await res.text()}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`API ${method} ${path}: ${res.status} ${text}`);
|
||||
|
||||
140
tests/e2e/test_dev_mode.sh
Executable file
140
tests/e2e/test_dev_mode.sh
Executable file
@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
# E2E regression suite for the local-dev escape hatches added in
|
||||
# fix/quickstart-bugless. These cover the exact user-facing breakages
|
||||
# that dropped out of the partial squash-merge of PR #1871:
|
||||
#
|
||||
# 1. GET /workspaces returns 200 with no bearer after tokens exist in
|
||||
# the DB — exercises the AdminAuth Tier-1b dev-mode hatch
|
||||
# (middleware/devmode.go::isDevModeFailOpen).
|
||||
# 2. GET /workspaces/:id/activity returns 200 with no bearer — the
|
||||
# same hatch applied to WorkspaceAuth.
|
||||
# 3. POST /workspaces/:id/a2a doesn't 502-SSRF on a loopback workspace
|
||||
# URL — exercises handlers/ssrf.go::devModeAllowsLoopback.
|
||||
# 4. GET /org/templates returns the curated set populated by
|
||||
# clone-manifest.sh — exercises infra/scripts/setup.sh + the
|
||||
# ListTemplates failure logging in handlers/org.go.
|
||||
#
|
||||
# Requires: platform running on :8080 with MOLECULE_ENV=development and
|
||||
# ADMIN_TOKEN unset. Matches the README quickstart env.
|
||||
#
|
||||
# Usage:
|
||||
# bash tests/e2e/test_dev_mode.sh
|
||||
set -euo pipefail
|
||||
|
||||
# shellcheck source=_lib.sh
|
||||
source "$(dirname "$0")/_lib.sh"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $1"
|
||||
FAIL=$((FAIL + 1))
|
||||
}
|
||||
|
||||
pass() {
|
||||
echo "PASS: $1"
|
||||
PASS=$((PASS + 1))
|
||||
}
|
||||
|
||||
check_http() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
if [ "$actual" = "$expected" ]; then
|
||||
pass "$desc (HTTP $actual)"
|
||||
else
|
||||
fail "$desc — expected HTTP $expected, got $actual"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Dev-mode escape-hatch regression tests ==="
|
||||
echo ""
|
||||
|
||||
# Pre-test: ensure MOLECULE_ENV=development and no ADMIN_TOKEN are in the
|
||||
# platform's env. The request path doesn't let us read the platform's
|
||||
# env directly, but we can verify the hatch is active by confirming the
|
||||
# expected behaviour under the conditions the test otherwise sets up.
|
||||
|
||||
e2e_cleanup_all_workspaces
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Section 1 — AdminAuth dev-mode hatch
|
||||
# ----------------------------------------------------------------------
|
||||
# Before fix: once any workspace had tokens in the DB, GET /workspaces
|
||||
# closed to unauthenticated callers and the Canvas broke. The hatch
|
||||
# keeps it open specifically in dev mode.
|
||||
|
||||
echo "--- Section 1: AdminAuth dev-mode hatch ---"
|
||||
|
||||
R=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/workspaces")
|
||||
check_http "GET /workspaces (empty DB)" "200" "$R"
|
||||
|
||||
# Create a workspace so tokens land in the DB.
|
||||
R=$(curl -s -w "\n%{http_code}" -X POST "$BASE/workspaces" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Dev-Mode-Test","tier":1}')
|
||||
CODE=$(echo "$R" | tail -n1)
|
||||
BODY=$(echo "$R" | sed '$d')
|
||||
check_http "POST /workspaces (create)" "201" "$CODE"
|
||||
|
||||
WS_ID=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
if [ -z "$WS_ID" ]; then
|
||||
fail "Could not extract workspace ID from create response"
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Mint a test-token so AdminAuth now sees a live token on record. On
|
||||
# pre-fix builds the next /workspaces call would 401 — on post-fix it
|
||||
# must stay 200 because MOLECULE_ENV=development + ADMIN_TOKEN unset.
|
||||
curl -s -o /dev/null "$BASE/admin/workspaces/$WS_ID/test-token"
|
||||
|
||||
R=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/workspaces")
|
||||
check_http "GET /workspaces (after token minted, no bearer)" "200" "$R"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Section 2 — WorkspaceAuth dev-mode hatch
|
||||
# ----------------------------------------------------------------------
|
||||
# Before fix: /workspaces/:id/activity 401'd once tokens existed —
|
||||
# the Canvas side panel's chat history load broke.
|
||||
|
||||
echo ""
|
||||
echo "--- Section 2: WorkspaceAuth dev-mode hatch ---"
|
||||
|
||||
R=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
"$BASE/workspaces/$WS_ID/activity?type=a2a_receive&limit=50")
|
||||
check_http "GET /workspaces/:id/activity (no bearer)" "200" "$R"
|
||||
|
||||
R=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
"$BASE/workspaces/$WS_ID/delegations")
|
||||
check_http "GET /workspaces/:id/delegations (no bearer)" "200" "$R"
|
||||
|
||||
R=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/approvals/pending")
|
||||
check_http "GET /approvals/pending (no bearer)" "200" "$R"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Section 3 — Template registry populated by setup.sh
|
||||
# ----------------------------------------------------------------------
|
||||
# Before fix: setup.sh didn't run clone-manifest.sh so the template
|
||||
# palette was empty and the molecule-dev in-tree copy was broken.
|
||||
|
||||
echo ""
|
||||
echo "--- Section 3: Template registry ---"
|
||||
|
||||
R=$(curl -s "$BASE/org/templates")
|
||||
COUNT=$(echo "$R" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0")
|
||||
if [ "$COUNT" -gt 0 ]; then
|
||||
pass "GET /org/templates returns $COUNT template(s)"
|
||||
else
|
||||
fail "GET /org/templates returned empty list — is clone-manifest.sh run? (bash scripts/clone-manifest.sh manifest.json workspace-configs-templates/ org-templates/ plugins/)"
|
||||
fi
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# ----------------------------------------------------------------------
|
||||
curl -s -X DELETE "$BASE/workspaces/$WS_ID?confirm=true" > /dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@ -4,10 +4,32 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// devModeAllowsLoopback reports whether the SSRF defence should permit
|
||||
// http://127.0.0.1:<port> workspace URLs. True only when MOLECULE_ENV is
|
||||
// a dev value — this is the same convention the middleware dev-mode
|
||||
// escape hatch uses (handlers/admin_test_token.go, middleware/devmode.go).
|
||||
//
|
||||
// Why: on a self-hosted Docker setup the provisioner publishes each
|
||||
// container's A2A port on 127.0.0.1:<ephemeral> and writes that URL
|
||||
// to workspaces.url. The A2A proxy on the host platform needs to POST
|
||||
// to that same 127.0.0.1:<port> to reach the container — there's no
|
||||
// other reachable address. SaaS never hits this branch because hosted
|
||||
// tenants run MOLECULE_ENV=production (enforced by the crypto strict-
|
||||
// init path) and the workspace URL is the tenant EC2's VPC-private IP.
|
||||
//
|
||||
// The relaxation is narrowly scoped to loopback IPv4 + ::1 — the
|
||||
// metadata, CGNAT, TEST-NET, and link-local guards stay blocked even
|
||||
// in dev mode.
|
||||
func devModeAllowsLoopback() bool {
|
||||
env := strings.ToLower(strings.TrimSpace(os.Getenv("MOLECULE_ENV")))
|
||||
return env == "development" || env == "dev"
|
||||
}
|
||||
|
||||
// isSafeURL validates that a URL resolves to a publicly-routable address,
|
||||
// preventing A2A requests from being redirected to internal/cloud-metadata
|
||||
// infrastructure (SSRF, CWE-918). Workspace URLs come from DB/Redis caches
|
||||
@ -30,7 +52,7 @@ func isSafeURL(rawURL string) error {
|
||||
return fmt.Errorf("empty hostname")
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if (ip.IsLoopback() && !testAllowLoopback) || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
|
||||
if (ip.IsLoopback() && !testAllowLoopback && !devModeAllowsLoopback()) || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
|
||||
return fmt.Errorf("forbidden loopback/unspecified/link-local IP: %s", ip)
|
||||
}
|
||||
if isPrivateOrMetadataIP(ip) {
|
||||
@ -50,7 +72,7 @@ func isSafeURL(rawURL string) error {
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if (ip.IsLoopback() && !testAllowLoopback) || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
|
||||
if (ip.IsLoopback() && !testAllowLoopback && !devModeAllowsLoopback()) || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
|
||||
return fmt.Errorf("hostname %s resolves to forbidden link-local/loopback IP: %s", host, ip)
|
||||
}
|
||||
if isPrivateOrMetadataIP(ip) {
|
||||
@ -117,8 +139,9 @@ func isPrivateOrMetadataIP(ip net.IP) bool {
|
||||
|
||||
// IPv6 path — .To4() was nil so this is a real v6 address.
|
||||
// ::1 (loopback) — treat as blocked here too for defense-in-depth,
|
||||
// unless tests have opted into loopback via testAllowLoopback.
|
||||
if ip.IsLoopback() && !testAllowLoopback {
|
||||
// unless tests have opted into loopback via testAllowLoopback OR
|
||||
// MOLECULE_ENV is a dev value (mirrors the v4 relaxation above).
|
||||
if ip.IsLoopback() && !testAllowLoopback && !devModeAllowsLoopback() {
|
||||
return true
|
||||
}
|
||||
// Link-local fe80::/10 — always blocked.
|
||||
|
||||
@ -235,3 +235,95 @@ func TestIsSafeURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Dev-mode loopback relaxation — lock in the local-dev SSRF escape
|
||||
// hatch. The provisioner on a self-hosted Docker setup publishes
|
||||
// workspace A2A ports on 127.0.0.1:<ephemeral>, so the A2A proxy must
|
||||
// POST to loopback. Without this relaxation every Canvas chat send
|
||||
// returned 502 on the host-run platform.
|
||||
//
|
||||
// SaaS safety: the relaxation fires ONLY when MOLECULE_ENV is a dev
|
||||
// value. Production (MOLECULE_ENV=production) must continue to block
|
||||
// loopback. Every other blocked range (metadata 169.254/16, TEST-NET,
|
||||
// CGNAT, link-local) must stay blocked even in dev mode.
|
||||
|
||||
func TestIsSafeURL_DevModeAllowsLoopback(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
cases := []string{
|
||||
"http://127.0.0.1:59806",
|
||||
"http://127.0.0.1:8000/a2a",
|
||||
"http://[::1]:8000",
|
||||
}
|
||||
for _, u := range cases {
|
||||
t.Run(u, func(t *testing.T) {
|
||||
if err := isSafeURL(u); err != nil {
|
||||
t.Errorf("dev mode should allow %q, got %v", u, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSafeURL_DevModeShortAlias(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ENV", "dev")
|
||||
if err := isSafeURL("http://127.0.0.1:59806"); err != nil {
|
||||
t.Errorf("MOLECULE_ENV=dev should allow loopback, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSafeURL_Production_StillBlocksLoopback(t *testing.T) {
|
||||
// SaaS-safety guarantee: production tenants must keep blocking
|
||||
// loopback URLs. A workspace registering a loopback URL in prod
|
||||
// is almost certainly an attack targeting co-located admin
|
||||
// services — the SSRF defence MUST keep firing.
|
||||
t.Setenv("MOLECULE_ENV", "production")
|
||||
if err := isSafeURL("http://127.0.0.1:8080"); err == nil {
|
||||
t.Error("production must block loopback, got nil error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSafeURL_DevMode_StillBlocksOtherRanges(t *testing.T) {
|
||||
// The relaxation is narrow — only loopback. Metadata / CGNAT /
|
||||
// TEST-NET / link-local must still fire in dev mode. A malicious
|
||||
// workspace in a dev install must NOT reach cloud metadata.
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
stillBlocked := []string{
|
||||
"http://169.254.169.254/latest/meta-data/", // AWS IMDS
|
||||
"http://192.0.2.1:8080", // TEST-NET-1
|
||||
"http://100.64.0.1:8080", // CGNAT
|
||||
"http://0.0.0.0:8080", // unspecified
|
||||
"http://224.0.0.1/", // link-local multicast
|
||||
}
|
||||
for _, u := range stillBlocked {
|
||||
t.Run(u, func(t *testing.T) {
|
||||
if err := isSafeURL(u); err == nil {
|
||||
t.Errorf("dev mode must still block %q", u)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevModeAllowsLoopback_Predicate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, env string
|
||||
want bool
|
||||
}{
|
||||
{"development", "development", true},
|
||||
{"dev", "dev", true},
|
||||
{"Development (case)", "Development", true},
|
||||
{"DEV (case)", "DEV", true},
|
||||
{" dev (whitespace)", " dev ", true},
|
||||
{"production", "production", false},
|
||||
{"staging", "staging", false},
|
||||
{"empty string", "", false},
|
||||
{"typo devel", "devel", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ENV", tc.env)
|
||||
got := devModeAllowsLoopback()
|
||||
if got != tc.want {
|
||||
t.Errorf("devModeAllowsLoopback() with MOLECULE_ENV=%q = %v, want %v", tc.env, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user