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",
|
"identifier": "http:default",
|
||||||
"allow": [
|
"allow": [
|
||||||
{ "url": "http://*" },
|
{ "url": "http://localhost" },
|
||||||
{ "url": "http://*:*" },
|
{ "url": "http://localhost:*" },
|
||||||
|
{ "url": "http://127.0.0.1" },
|
||||||
|
{ "url": "http://127.0.0.1:*" },
|
||||||
{ "url": "https://*" },
|
{ "url": "https://*" },
|
||||||
{ "url": "https://*:*" }
|
{ "url": "https://*:*" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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": {
|
"plugins": {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
isCloudInstance,
|
isCloudInstance,
|
||||||
} from "@/lib/config";
|
} from "@/lib/config";
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
|
import { tokenTransportError } from "@/lib/security";
|
||||||
|
|
||||||
export type ConnectionPreset = "cloud" | "self-hosted";
|
export type ConnectionPreset = "cloud" | "self-hosted";
|
||||||
|
|
||||||
@@ -81,6 +82,13 @@ export function SettingsForm({
|
|||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
setHealth({ status: "checking", message: "Connecting..." });
|
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);
|
const result = await checkConnection(baseUrl, token || undefined);
|
||||||
setHealth(result);
|
setHealth(result);
|
||||||
setChecking(false);
|
setChecking(false);
|
||||||
@@ -112,6 +120,11 @@ export function SettingsForm({
|
|||||||
setErrors({ token: "API key is required for Honcho Cloud" });
|
setErrors({ token: "API key is required for Honcho Cloud" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const transportError = token.trim() ? tokenTransportError(result.data.baseUrl) : null;
|
||||||
|
if (transportError) {
|
||||||
|
setErrors({ baseUrl: transportError });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setErrors({});
|
setErrors({});
|
||||||
|
|
||||||
let id: string;
|
let id: string;
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||||
import { useDemo } from "@/hooks/useDemo";
|
import { useDemo } from "@/hooks/useDemo";
|
||||||
import { COLOR } from "@/lib/constants";
|
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 {
|
interface Props {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -50,6 +54,15 @@ export function WebhookManager({ workspaceId }: Props) {
|
|||||||
setTimeout(() => setTestResult(null), 5000);
|
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 : [];
|
const list = Array.isArray(webhooks) ? webhooks : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -161,7 +174,7 @@ export function WebhookManager({ workspaceId }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void open((wh as { url: string }).url)}
|
onClick={() => void handleOpenWebhook((wh as { url: string }).url)}
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
style={{
|
style={{
|
||||||
color: "var(--text-4)",
|
color: "var(--text-4)",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const POLL_INTERVAL_MS = 30_000;
|
|||||||
export function useHealthStatus() {
|
export function useHealthStatus() {
|
||||||
const { active } = useInstances();
|
const { active } = useInstances();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["health", active?.id, active?.baseUrl, active?.token],
|
queryKey: ["health", active?.id, active?.baseUrl, Boolean(active?.token)],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!active) throw new Error("No active instance");
|
if (!active) throw new Error("No active instance");
|
||||||
return checkConnection(active.baseUrl, active.token || undefined);
|
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();
|
const { demo } = useDemo();
|
||||||
return <span data-testid="demo-flag">{String(demo)}</span>;
|
return <span data-testid="demo-flag">{String(demo)}</span>;
|
||||||
}
|
}
|
||||||
// After the fix, DemoProvider wraps the app at the root (main.tsx /
|
// After the fix, DemoProvider and MetadataProvider wrap the app at
|
||||||
// __root.tsx) so consumers anywhere in the tree resolve. This test
|
// the root (main.tsx) so consumers anywhere in the tree resolve.
|
||||||
// renders a consumer as a sibling of the router under the same provider
|
// This test renders a consumer as a sibling of the router under the
|
||||||
// the production wiring uses.
|
// same providers the production wiring uses.
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
expect(() => {
|
expect(() => {
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|||||||
@@ -39,4 +39,23 @@ describe("useHealthStatus", () => {
|
|||||||
const { result } = renderHook(() => useHealthStatus(), { wrapper: wrap(qc) });
|
const { result } = renderHook(() => useHealthStatus(), { wrapper: wrap(qc) });
|
||||||
await waitFor(() => expect(result.current.data?.status).toBe("ok"));
|
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();
|
const store = loadStore();
|
||||||
expect(store.instances).toHaveLength(1);
|
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", () => {
|
describe("SettingsForm — edit mode auto-detects cloud", () => {
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import { cleanup } from "@testing-library/react";
|
import { cleanup } from "@testing-library/react";
|
||||||
import { afterEach, vi } from "vitest";
|
import { afterEach, vi } from "vitest";
|
||||||
|
|
||||||
// jsdom doesn't implement matchMedia; theme code reads it on mount.
|
// jsdom defines scrollTo but leaves it unimplemented; router scroll restoration calls it.
|
||||||
if (!window.scrollTo) {
|
window.scrollTo = vi.fn() as unknown as typeof window.scrollTo;
|
||||||
window.scrollTo = vi.fn() as unknown as typeof window.scrollTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window.matchMedia) {
|
if (!window.matchMedia) {
|
||||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user