Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,16 @@ def _is_private_client_address(host: str | None) -> bool:


class EnforceCanonicalHttpsMiddleware(BaseHTTPMiddleware):
# Liveness endpoints must answer over plain HTTP: container/orchestrator
# health checks and internal uptime monitors call them directly, without the
# X-Forwarded-Proto headers that browser traffic carries. Redirecting them
# breaks those checks and, for the frontend probe, defeats the fallback.
_HEALTH_PATHS = frozenset({"/health", "/api/health"})

async def dispatch(self, request: Request, call_next):
if request.url.path in self._HEALTH_PATHS:
return await call_next(request)

if self._is_request_https(request):
return await call_next(request)

Expand Down
41 changes: 32 additions & 9 deletions backend/tests/test_security_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -929,11 +929,14 @@ async def test_safe_http_requests_redirect_to_canonical_https_origin(
transport=ASGITransport(app=app),
base_url="http://nojoin.example.com",
) as client:
response = await client.get("/api/health?probe=1")
# Health paths are exempt from HTTPS enforcement, so use a guarded path
# to prove plain-HTTP safe requests still redirect to canonical HTTPS.
response = await client.get("/api/v1/system/health?probe=1")

assert response.status_code == 307
assert (
response.headers["location"] == "https://nojoin.example.com/api/health?probe=1"
response.headers["location"]
== "https://nojoin.example.com/api/v1/system/health?probe=1"
)


Expand All @@ -946,7 +949,8 @@ async def test_unsafe_http_requests_are_rejected(monkeypatch) -> None:
transport=ASGITransport(app=app),
base_url="http://nojoin.example.com",
) as client:
response = await client.post("/api/health")
# A guarded path (health is exempt) proves unsafe plain-HTTP is rejected.
response = await client.post("/api/v1/system/health")

assert response.status_code == 400
assert response.json() == {
Expand All @@ -963,16 +967,18 @@ async def test_forwarded_https_proxy_requests_are_accepted(monkeypatch) -> None:
transport=ASGITransport(app=app),
base_url="http://nojoin.example.com",
) as client:
# Health paths bypass HTTPS enforcement, so exercise the proxy-scheme
# gate with a guarded endpoint: a forwarded-https request is accepted
# (reaches auth and 401s) rather than being redirected to canonical HTTPS.
response = await client.get(
"/api/health",
"/api/v1/system/health",
headers={
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "nojoin.example.com",
},
)

assert response.status_code == 200
assert response.json() == {"status": "ok"}
assert response.status_code == 401


@pytest.mark.anyio
Expand All @@ -986,16 +992,33 @@ async def test_forwarded_https_proxy_requests_accept_host_headers_with_ports(
transport=ASGITransport(app=app),
base_url="http://localhost:14443",
) as client:
# Health paths bypass HTTPS enforcement; use a guarded endpoint to prove
# the host-with-port normalisation still accepts the forwarded request.
response = await client.get(
"/api/health",
"/api/v1/system/health",
headers={
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "localhost:14443",
},
)

assert response.status_code == 200
assert response.json() == {"status": "ok"}
assert response.status_code == 401


@pytest.mark.anyio
async def test_liveness_endpoints_bypass_https_enforcement() -> None:
# Container/orchestrator probes and internal uptime monitors call the
# liveness endpoints directly over plain HTTP, without forwarded-proto
# headers. They must answer 200 rather than being redirected to HTTPS.
app, _ = _build_app(initialized=True)

async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
for path in ("/health", "/api/health"):
response = await client.get(path)
assert response.status_code == 200, path
assert response.json() == {"status": "ok"}


@pytest.mark.anyio
Expand Down
7 changes: 3 additions & 4 deletions frontend/src/components/MeetingControls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,9 @@ vi.mock("next/navigation", () => ({
}),
}));

vi.mock("@/lib/serviceStatusStore", () => ({
useServiceStatusStore: () => ({
backend: true,
}),
vi.mock("@/lib/connectivity/monitor", () => ({
useConnectivityStore: (selector: (state: { status: string }) => unknown) =>
selector({ status: "online" }),
}));

vi.mock("@/lib/capture/CaptureProvider", () => ({
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/MeetingControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import { Mic } from "lucide-react";
import { useRouter } from "next/navigation";
import { useServiceStatusStore } from "@/lib/serviceStatusStore";
import { useConnectivityStore } from "@/lib/connectivity/monitor";
import { isReachable } from "@/lib/connectivity/reducer";
import { useCapture } from "@/lib/capture/CaptureProvider";
import { useNotificationStore } from "@/lib/notificationStore";

Expand All @@ -27,7 +28,7 @@ export default function MeetingControls({
onMeetingEnd,
variant = "sidebar",
}: MeetingControlsProps) {
const { backend } = useServiceStatusStore();
const backend = useConnectivityStore((state) => isReachable(state.status));
const {
controller,
finalizeRetry,
Expand Down
89 changes: 49 additions & 40 deletions frontend/src/components/ServiceStatusAlerts.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { act, render, waitFor } from "@testing-library/react";
import { render, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import ServiceStatusAlerts from "./ServiceStatusAlerts";
import type { Reachability } from "@/lib/connectivity/reducer";

const addNotification = vi.fn();
const removeActiveNotification = vi.fn();
const checkBackend = vi.fn().mockResolvedValue(undefined);
const refreshHealth = vi.fn().mockResolvedValue(undefined);
const startPolling = vi.fn();
const stopPolling = vi.fn();

let connectivityStatus: Reachability = "online";

const serviceStatusState = {
backend: true,
db: true,
worker: true,
deploymentWarnings: [] as Array<{
Expand All @@ -19,8 +21,7 @@ const serviceStatusState = {
title: string;
message: string;
}>,
backendFailCount: 0,
checkBackend,
refreshHealth,
startPolling,
stopPolling,
};
Expand All @@ -36,19 +37,25 @@ vi.mock("@/lib/serviceStatusStore", () => ({
useServiceStatusStore: () => serviceStatusState,
}));

vi.mock("@/lib/connectivity/monitor", () => ({
useConnectivityStore: (selector: (state: { status: Reachability }) => unknown) =>
selector({ status: connectivityStatus }),
startConnectivityMonitor: vi.fn(),
stopConnectivityMonitor: vi.fn(),
}));

describe("ServiceStatusAlerts", () => {
beforeEach(() => {
addNotification.mockReset();
removeActiveNotification.mockReset();
checkBackend.mockClear();
refreshHealth.mockClear();
startPolling.mockClear();
stopPolling.mockClear();
addNotification.mockReturnValue("placeholder-toast-id");
serviceStatusState.backend = true;
connectivityStatus = "online";
serviceStatusState.db = true;
serviceStatusState.worker = true;
serviceStatusState.deploymentWarnings = [];
serviceStatusState.backendFailCount = 0;
});

it("creates one persistent placeholder warning toast", async () => {
Expand Down Expand Up @@ -99,49 +106,51 @@ describe("ServiceStatusAlerts", () => {
rerender(<ServiceStatusAlerts />);

await waitFor(() => {
expect(removeActiveNotification).toHaveBeenCalledWith(
"placeholder-toast-id",
);
expect(removeActiveNotification).toHaveBeenCalledWith("placeholder-toast-id");
});
});

it("keeps backend-offline and placeholder-warning notifications separate", async () => {
vi.useFakeTimers();
addNotification
.mockReturnValueOnce("backend-toast-id")
.mockReturnValueOnce("placeholder-toast-id");
serviceStatusState.backend = false;
serviceStatusState.backendFailCount = 2;
serviceStatusState.deploymentWarnings = [
{
code: "placeholder_first_run_password",
key: "FIRST_RUN_PASSWORD",
title: "Placeholder bootstrap password configured",
message: "Update it.",
},
];
it("raises the unreachable alert only in the unreachable state", async () => {
connectivityStatus = "unreachable";

const { rerender } = render(<ServiceStatusAlerts />);
render(<ServiceStatusAlerts />);

await act(async () => {
await vi.advanceTimersByTimeAsync(5001);
await waitFor(() => {
expect(addNotification).toHaveBeenCalledWith({
type: "error",
message: "Server Unreachable: Cannot connect to Nojoin Backend API.",
persistent: true,
});
});
});

serviceStatusState.backendFailCount = 3;
rerender(<ServiceStatusAlerts />);
it("shows a distinct offline message when the browser is offline", async () => {
connectivityStatus = "offline";

expect(addNotification).toHaveBeenCalledWith({
type: "error",
message: "Server Unreachable: Cannot connect to Nojoin Backend API.",
persistent: true,
render(<ServiceStatusAlerts />);

await waitFor(() => {
expect(addNotification).toHaveBeenCalledWith({
type: "error",
message: "You're offline. Check your network connection.",
persistent: true,
});
});
});

it("does not raise a reachability alert while online or checking", async () => {
connectivityStatus = "checking";

expect(addNotification).not.toHaveBeenCalledWith({
type: "warning",
message: expect.stringContaining("placeholder secrets"),
persistent: true,
render(<ServiceStatusAlerts />);

await waitFor(() => {
expect(startPolling).toHaveBeenCalled();
});

vi.useRealTimers();
expect(addNotification).not.toHaveBeenCalledWith(
expect.objectContaining({
message: "Server Unreachable: Cannot connect to Nojoin Backend API.",
}),
);
});
});
Loading