Merge pull request #22 from offendingcommit/fix/web-security-hardening
fix: harden token and URL transport security
This commit is contained in:
@@ -8,8 +8,10 @@
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "http://*" },
|
||||
{ "url": "http://*:*" },
|
||||
{ "url": "http://localhost" },
|
||||
{ "url": "http://localhost:*" },
|
||||
{ "url": "http://127.0.0.1" },
|
||||
{ "url": "http://127.0.0.1:*" },
|
||||
{ "url": "https://*" },
|
||||
{ "url": "https://*:*" }
|
||||
]
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http://localhost:* http://127.0.0.1:* https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
isCloudInstance,
|
||||
} from "@/lib/config";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { tokenTransportError } from "@/lib/security";
|
||||
|
||||
export type ConnectionPreset = "cloud" | "self-hosted";
|
||||
|
||||
@@ -81,6 +82,13 @@ export function SettingsForm({
|
||||
async function handleTest() {
|
||||
setChecking(true);
|
||||
setHealth({ status: "checking", message: "Connecting..." });
|
||||
const transportError = token.trim() ? tokenTransportError(baseUrl) : null;
|
||||
if (transportError) {
|
||||
setErrors({ baseUrl: transportError });
|
||||
setHealth({ status: "unreachable", message: transportError });
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
const result = await checkConnection(baseUrl, token || undefined);
|
||||
setHealth(result);
|
||||
setChecking(false);
|
||||
@@ -112,6 +120,11 @@ export function SettingsForm({
|
||||
setErrors({ token: "API key is required for Honcho Cloud" });
|
||||
return;
|
||||
}
|
||||
const transportError = token.trim() ? tokenTransportError(result.data.baseUrl) : null;
|
||||
if (transportError) {
|
||||
setErrors({ baseUrl: transportError });
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
|
||||
let id: string;
|
||||
|
||||
@@ -13,8 +13,12 @@ import { Input } from "@/components/ui/input";
|
||||
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||
import { useDemo } from "@/hooks/useDemo";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { isHttpOrHttpsUrl, isSafeExternalUrl } from "@/lib/security";
|
||||
|
||||
const urlSchema = z.string().url({ message: "Must be a valid URL" });
|
||||
const urlSchema = z
|
||||
.string()
|
||||
.url({ message: "Must be a valid URL" })
|
||||
.refine(isHttpOrHttpsUrl, { message: "Webhook URL must use http or https" });
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
@@ -50,6 +54,15 @@ export function WebhookManager({ workspaceId }: Props) {
|
||||
setTimeout(() => setTestResult(null), 5000);
|
||||
};
|
||||
|
||||
const handleOpenWebhook = async (webhookUrl: string) => {
|
||||
if (!isSafeExternalUrl(webhookUrl)) {
|
||||
setTestResult(`Blocked unsafe webhook URL: ${webhookUrl}`);
|
||||
setTimeout(() => setTestResult(null), 5000);
|
||||
return;
|
||||
}
|
||||
await open(webhookUrl);
|
||||
};
|
||||
|
||||
const list = Array.isArray(webhooks) ? webhooks : [];
|
||||
|
||||
return (
|
||||
@@ -161,7 +174,7 @@ export function WebhookManager({ workspaceId }: Props) {
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void open((wh as { url: string }).url)}
|
||||
onClick={() => void handleOpenWebhook((wh as { url: string }).url)}
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
color: "var(--text-4)",
|
||||
|
||||
@@ -7,7 +7,7 @@ const POLL_INTERVAL_MS = 30_000;
|
||||
export function useHealthStatus() {
|
||||
const { active } = useInstances();
|
||||
return useQuery({
|
||||
queryKey: ["health", active?.id, active?.baseUrl, active?.token],
|
||||
queryKey: ["health", active?.id, active?.baseUrl, Boolean(active?.token)],
|
||||
queryFn: async () => {
|
||||
if (!active) throw new Error("No active instance");
|
||||
return checkConnection(active.baseUrl, active.token || undefined);
|
||||
|
||||
38
packages/web/src/lib/security.ts
Normal file
38
packages/web/src/lib/security.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]);
|
||||
|
||||
function parseUrl(value: string): URL | null {
|
||||
try {
|
||||
return new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoopbackUrl(value: string): boolean {
|
||||
const parsed = parseUrl(value);
|
||||
if (!parsed) return false;
|
||||
return LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase());
|
||||
}
|
||||
|
||||
export function isHttpOrHttpsUrl(value: string): boolean {
|
||||
const parsed = parseUrl(value);
|
||||
return parsed?.protocol === "https:" || parsed?.protocol === "http:";
|
||||
}
|
||||
|
||||
export function isSafeExternalUrl(value: string): boolean {
|
||||
const parsed = parseUrl(value);
|
||||
return parsed?.protocol === "https:" || parsed?.protocol === "http:";
|
||||
}
|
||||
|
||||
export function isSecureTokenTransport(baseUrl: string): boolean {
|
||||
const parsed = parseUrl(baseUrl);
|
||||
if (!parsed) return false;
|
||||
if (parsed.protocol === "https:") return true;
|
||||
if (parsed.protocol === "http:" && LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function tokenTransportError(baseUrl: string): string | null {
|
||||
if (isSecureTokenTransport(baseUrl)) return null;
|
||||
return "API tokens require HTTPS unless connecting to localhost.";
|
||||
}
|
||||
@@ -43,10 +43,10 @@ describe("Sidebar/useDemo availability across routes", () => {
|
||||
const { demo } = useDemo();
|
||||
return <span data-testid="demo-flag">{String(demo)}</span>;
|
||||
}
|
||||
// After the fix, DemoProvider wraps the app at the root (main.tsx /
|
||||
// __root.tsx) so consumers anywhere in the tree resolve. This test
|
||||
// renders a consumer as a sibling of the router under the same provider
|
||||
// the production wiring uses.
|
||||
// After the fix, DemoProvider and MetadataProvider wrap the app at
|
||||
// the root (main.tsx) so consumers anywhere in the tree resolve.
|
||||
// This test renders a consumer as a sibling of the router under the
|
||||
// same providers the production wiring uses.
|
||||
localStorage.clear();
|
||||
expect(() => {
|
||||
const router = createRouter({
|
||||
|
||||
@@ -39,4 +39,23 @@ describe("useHealthStatus", () => {
|
||||
const { result } = renderHook(() => useHealthStatus(), { wrapper: wrap(qc) });
|
||||
await waitFor(() => expect(result.current.data?.status).toBe("ok"));
|
||||
});
|
||||
|
||||
it("does not include raw tokens in query cache keys", async () => {
|
||||
saveStore({
|
||||
instances: [
|
||||
{ id: "i1", name: "Local", baseUrl: "http://localhost:8000", token: "super-secret-token" },
|
||||
],
|
||||
activeId: "i1",
|
||||
});
|
||||
httpFetch.mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderHook(() => useHealthStatus(), { wrapper: wrap(qc) });
|
||||
await waitFor(() => expect(httpFetch).toHaveBeenCalled());
|
||||
|
||||
const cacheKeys = qc
|
||||
.getQueryCache()
|
||||
.getAll()
|
||||
.map((query) => JSON.stringify(query.queryKey));
|
||||
expect(cacheKeys.join("\n")).not.toContain("super-secret-token");
|
||||
});
|
||||
});
|
||||
|
||||
37
packages/web/src/test/security.test.ts
Normal file
37
packages/web/src/test/security.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isHttpOrHttpsUrl,
|
||||
isSafeExternalUrl,
|
||||
isSecureTokenTransport,
|
||||
tokenTransportError,
|
||||
} from "@/lib/security";
|
||||
|
||||
describe("security URL helpers", () => {
|
||||
it("only allows http and https URLs for external OS opens", () => {
|
||||
expect(isSafeExternalUrl("https://example.com/webhook")).toBe(true);
|
||||
expect(isSafeExternalUrl("http://localhost:3000/webhook")).toBe(true);
|
||||
expect(isSafeExternalUrl("file:///etc/passwd")).toBe(false);
|
||||
expect(isSafeExternalUrl("javascript:alert(1)")).toBe(false);
|
||||
expect(isSafeExternalUrl("openconcho://settings")).toBe(false);
|
||||
});
|
||||
|
||||
it("only accepts http and https webhook endpoints", () => {
|
||||
expect(isHttpOrHttpsUrl("https://hooks.example.com/a")).toBe(true);
|
||||
expect(isHttpOrHttpsUrl("http://hooks.example.com/a")).toBe(true);
|
||||
expect(isHttpOrHttpsUrl("ftp://hooks.example.com/a")).toBe(false);
|
||||
expect(isHttpOrHttpsUrl("notaurl")).toBe(false);
|
||||
});
|
||||
|
||||
it("requires HTTPS before sending tokens to non-loopback hosts", () => {
|
||||
expect(isSecureTokenTransport("https://honcho.example.com")).toBe(true);
|
||||
expect(isSecureTokenTransport("http://localhost:8000")).toBe(true);
|
||||
expect(isSecureTokenTransport("http://127.0.0.1:8000")).toBe(true);
|
||||
expect(isSecureTokenTransport("http://192.168.1.50:8000")).toBe(false);
|
||||
expect(isSecureTokenTransport("http://100.67.206.76:8000")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a user-facing error for insecure token transport", () => {
|
||||
expect(tokenTransportError("http://100.67.206.76:8000")).toMatch(/HTTPS/);
|
||||
expect(tokenTransportError("https://honcho.example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,24 @@ describe("SettingsForm — self-hosted preset", () => {
|
||||
const store = loadStore();
|
||||
expect(store.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("blocks saving a token for a non-localhost HTTP endpoint", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderForm(<SettingsForm instance={null} preset="self-hosted" />);
|
||||
const baseUrl = screen.getByPlaceholderText("http://localhost:8000");
|
||||
await user.clear(baseUrl);
|
||||
await user.type(baseUrl, "http://100.67.206.76:8000");
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/required only if your instance has auth enabled/i),
|
||||
"secret-token",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Add Instance/i }));
|
||||
|
||||
expect(
|
||||
screen.getByText(/API tokens require HTTPS unless connecting to localhost/i),
|
||||
).toBeInTheDocument();
|
||||
expect(loadStore().instances).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsForm — edit mode auto-detects cloud", () => {
|
||||
|
||||
@@ -2,10 +2,8 @@ import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
// jsdom doesn't implement matchMedia; theme code reads it on mount.
|
||||
if (!window.scrollTo) {
|
||||
window.scrollTo = vi.fn() as unknown as typeof window.scrollTo;
|
||||
}
|
||||
// jsdom defines scrollTo but leaves it unimplemented; router scroll restoration calls it.
|
||||
window.scrollTo = vi.fn() as unknown as typeof window.scrollTo;
|
||||
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
|
||||
Reference in New Issue
Block a user