diff --git a/.changeset/fix-dev-auth-bypass-node-env.md b/.changeset/fix-dev-auth-bypass-node-env.md new file mode 100644 index 000000000..5e957f5c9 --- /dev/null +++ b/.changeset/fix-dev-auth-bypass-node-env.md @@ -0,0 +1,12 @@ +--- +"@voltagent/server-core": patch +--- + +fix(auth): require explicit NODE_ENV=development for dev auth bypass + +Previously, `isDevRequest()` treated any non-production `NODE_ENV` (including +`undefined`) as a development environment, allowing auth bypass with a simple +header. Deployments that forgot to set `NODE_ENV=production` were fully open. + +Now only `NODE_ENV=development` or `NODE_ENV=test` enable the dev bypass +(fail-closed). Undefined/empty `NODE_ENV` is treated as production. diff --git a/.changeset/fix-elysia-server-provider-tests.md b/.changeset/fix-elysia-server-provider-tests.md new file mode 100644 index 000000000..ab862cc0b --- /dev/null +++ b/.changeset/fix-elysia-server-provider-tests.md @@ -0,0 +1,5 @@ +--- +"@voltagent/server-elysia": patch +--- + +fix(server-elysia): update test mocks to match refactored http.createServer implementation diff --git a/packages/server-core/src/auth/utils.spec.ts b/packages/server-core/src/auth/utils.spec.ts index 5b1e7b590..3448b5f3e 100644 --- a/packages/server-core/src/auth/utils.spec.ts +++ b/packages/server-core/src/auth/utils.spec.ts @@ -1,11 +1,33 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { hasConsoleAccess, isDevRequest } from "./utils"; +import { hasConsoleAccess, isDevEnvironment, isDevRequest } from "./utils"; describe("auth utils", () => { afterEach(() => { vi.unstubAllEnvs(); }); + describe("isDevEnvironment", () => { + it("returns true for development", () => { + vi.stubEnv("NODE_ENV", "development"); + expect(isDevEnvironment()).toBe(true); + }); + + it("returns true for test", () => { + vi.stubEnv("NODE_ENV", "test"); + expect(isDevEnvironment()).toBe(true); + }); + + it("returns false for production", () => { + vi.stubEnv("NODE_ENV", "production"); + expect(isDevEnvironment()).toBe(false); + }); + + it("returns false for empty string (fail-closed)", () => { + vi.stubEnv("NODE_ENV", ""); + expect(isDevEnvironment()).toBe(false); + }); + }); + describe("isDevRequest", () => { it("accepts the dev header in non-production", () => { vi.stubEnv("NODE_ENV", "development"); @@ -25,6 +47,44 @@ describe("auth utils", () => { expect(isDevRequest(req)).toBe(true); }); + it("rejects the dev header when NODE_ENV is empty (fail-closed)", () => { + vi.stubEnv("NODE_ENV", ""); + + const req = new Request("http://localhost/api", { + headers: { "x-voltagent-dev": "true" }, + }); + + expect(isDevRequest(req)).toBe(false); + }); + + it("rejects the dev query param when NODE_ENV is empty", () => { + vi.stubEnv("NODE_ENV", ""); + + const req = new Request("http://localhost/ws?dev=true"); + + expect(isDevRequest(req)).toBe(false); + }); + + it("accepts the dev header in test environment", () => { + vi.stubEnv("NODE_ENV", "test"); + + const req = new Request("http://localhost/api", { + headers: { "x-voltagent-dev": "true" }, + }); + + expect(isDevRequest(req)).toBe(true); + }); + + it("rejects the dev header in production", () => { + vi.stubEnv("NODE_ENV", "production"); + + const req = new Request("http://localhost/api", { + headers: { "x-voltagent-dev": "true" }, + }); + + expect(isDevRequest(req)).toBe(false); + }); + it("rejects the dev query param in production", () => { vi.stubEnv("NODE_ENV", "production"); diff --git a/packages/server-core/src/auth/utils.ts b/packages/server-core/src/auth/utils.ts index c24bbc5dd..6fbef2f0b 100644 --- a/packages/server-core/src/auth/utils.ts +++ b/packages/server-core/src/auth/utils.ts @@ -2,37 +2,46 @@ * Authentication utility functions */ +/** + * Check if the current process is running in an explicit dev/test environment. + * Undefined/empty NODE_ENV is treated as production (fail-closed) to prevent + * accidental auth bypass on deployments that forget to set NODE_ENV. + */ +export function isDevEnvironment(): boolean { + return process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test"; +} + /** * Check if request is from development environment * - * Requires BOTH client header AND non-production environment for security. - * This prevents production bypass while allowing local development. + * Requires BOTH a client header AND an explicit dev/test environment for security. + * Undefined NODE_ENV is treated as production (fail-closed) to prevent + * accidental auth bypass on deployed servers that forgot to set NODE_ENV. * * @param req - The incoming HTTP request * @returns True if both dev header and non-production environment are present * * @example - * // Local development with header (typical case) - * NODE_ENV=undefined + x-voltagent-dev=true → true (auth bypassed) - * - * // Development with header (playground) + * // Development with header (typical case) * NODE_ENV=development + x-voltagent-dev=true → true (auth bypassed) * - * // Development without header (testing auth) - * NODE_ENV=undefined + no header → false (auth required) + * // Test with header + * NODE_ENV=test + x-voltagent-dev=true → true (auth bypassed) + * + * // Undefined NODE_ENV with header (deployed server) + * NODE_ENV=undefined + x-voltagent-dev=true → false (auth required) * * // Production with header (attacker attempt) * NODE_ENV=production + x-voltagent-dev=true → false (auth required) * * @security - * - Client header alone: Cannot bypass in production - * - Non-production env alone: Developer can still test auth + * - Client header alone: Cannot bypass auth + * - Dev/test env alone: Developer can still test auth * - Both required: Selective bypass for DX - * - Production is strictly protected (NODE_ENV=production) + * - Only NODE_ENV=development|test enables dev bypass (fail-closed) */ export function isDevRequest(req: Request): boolean { - // Treat undefined/empty NODE_ENV as development (only production is strict) - const isDevEnv = process.env.NODE_ENV !== "production"; + const isDevEnv = isDevEnvironment(); if (!isDevEnv) { return false; } diff --git a/packages/server-core/src/websocket/setup.ts b/packages/server-core/src/websocket/setup.ts index cfca7e329..eeb087206 100644 --- a/packages/server-core/src/websocket/setup.ts +++ b/packages/server-core/src/websocket/setup.ts @@ -12,6 +12,7 @@ import { requiresAuth } from "../auth/defaults"; import type { AuthNextConfig } from "../auth/next"; import { isAuthNextConfig, normalizeAuthNextConfig, resolveAuthNextAccess } from "../auth/next"; import type { AuthProvider } from "../auth/types"; +import { isDevEnvironment } from "../auth/utils"; import { handleWebSocketConnection } from "./handlers"; /** @@ -19,7 +20,7 @@ import { handleWebSocketConnection } from "./handlers"; */ function isDevWebSocketRequest(req: IncomingMessage): boolean { const hasDevHeader = req.headers["x-voltagent-dev"] === "true"; - const isDevEnv = process.env.NODE_ENV !== "production"; + const isDevEnv = isDevEnvironment(); return hasDevHeader && isDevEnv; } @@ -29,7 +30,7 @@ function isWebSocketDevBypass(req: IncomingMessage, url: URL): boolean { } const devParam = url.searchParams.get("dev"); - return devParam === "true" && process.env.NODE_ENV !== "production"; + return devParam === "true" && isDevEnvironment(); } /** diff --git a/packages/server-elysia/src/elysia-server-provider.spec.ts b/packages/server-elysia/src/elysia-server-provider.spec.ts index 3355ed96c..0210f5271 100644 --- a/packages/server-elysia/src/elysia-server-provider.spec.ts +++ b/packages/server-elysia/src/elysia-server-provider.spec.ts @@ -19,6 +19,18 @@ vi.mock("@voltagent/server-core", async () => { }; }); +// Mock node:http +const mockServer = { + listen: vi.fn(), + close: vi.fn(), + once: vi.fn(), + listening: true, +}; + +vi.mock("node:http", () => ({ + createServer: vi.fn(() => mockServer), +})); + describe("ElysiaServerProvider", () => { let provider: ElysiaServerProvider; const mockApp = { @@ -26,6 +38,7 @@ describe("ElysiaServerProvider", () => { stop: vi.fn(), routes: [], // For extractCustomEndpoints get: vi.fn(), // For configureApp test + fetch: vi.fn(), // For http server handler }; const mockDeps = { @@ -45,6 +58,17 @@ describe("ElysiaServerProvider", () => { beforeEach(() => { vi.spyOn(appFactory, "createApp").mockResolvedValue({ app: mockApp } as any); provider = new ElysiaServerProvider(mockDeps, { port: 3000 }); + + // Reset mock server behavior for each test + mockServer.listen.mockImplementation((_port, _hostname, callback) => { + // Call callback synchronously to simulate successful listen + callback(); + return mockServer; + }); + mockServer.close.mockImplementation((callback) => { + callback(); + }); + mockServer.once.mockReturnValue(mockServer); }); afterEach(() => { @@ -53,18 +77,18 @@ describe("ElysiaServerProvider", () => { }); it("should start the server", async () => { + const { createServer } = await import("node:http"); await provider.start(); expect(appFactory.createApp).toHaveBeenCalled(); - expect(mockApp.listen).toHaveBeenCalledWith({ - port: 3000, - hostname: "0.0.0.0", - }); + expect(createServer).toHaveBeenCalled(); + expect(mockServer.listen).toHaveBeenCalledWith(3000, "0.0.0.0", expect.any(Function)); }); it("should stop the server", async () => { await provider.start(); await provider.stop(); expect(mockApp.stop).toHaveBeenCalled(); + expect(mockServer.close).toHaveBeenCalled(); }); it("should throw if already running", async () => { @@ -88,6 +112,7 @@ describe("ElysiaServerProvider", () => { stop: vi.fn(), routes: [{ method: "GET", path: "/custom-test" }], get: vi.fn(), + fetch: vi.fn(), }; vi.spyOn(appFactory, "createApp").mockResolvedValue({ app: localMockApp } as any); @@ -112,9 +137,22 @@ describe("ElysiaServerProvider", () => { }); it("should handle startup errors and release port", async () => { - // Mock app.listen to throw - mockApp.listen.mockImplementationOnce(() => { - throw new Error("Startup failed"); + // Mock server.once to capture the error handler, then trigger it + let errorHandler: ((err: Error) => void) | undefined; + mockServer.once.mockImplementation((event, handler) => { + if (event === "error") { + errorHandler = handler; + } + return mockServer; + }); + + // Mock server.listen to trigger the error + mockServer.listen.mockImplementation(() => { + // Simulate an error during listen by calling the error handler + if (errorHandler) { + errorHandler(new Error("Startup failed")); + } + return mockServer; }); await expect(provider.start()).rejects.toThrow("Startup failed");