Skip to content
Open
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
12 changes: 12 additions & 0 deletions .changeset/fix-dev-auth-bypass-node-env.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/fix-elysia-server-provider-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@voltagent/server-elysia": patch
---

fix(server-elysia): update test mocks to match refactored http.createServer implementation
62 changes: 61 additions & 1 deletion packages/server-core/src/auth/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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");

Expand Down
35 changes: 22 additions & 13 deletions packages/server-core/src/auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
5 changes: 3 additions & 2 deletions packages/server-core/src/websocket/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ 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";

/**
* Helper to check dev request for WebSocket IncomingMessage
*/
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;
}

Expand All @@ -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();
}

/**
Expand Down
52 changes: 45 additions & 7 deletions packages/server-elysia/src/elysia-server-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,26 @@ 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 = {
listen: vi.fn().mockReturnValue({}),
stop: vi.fn(),
routes: [], // For extractCustomEndpoints
get: vi.fn(), // For configureApp test
fetch: vi.fn(), // For http server handler
};

const mockDeps = {
Expand All @@ -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(() => {
Expand All @@ -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 () => {
Expand All @@ -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);
Expand All @@ -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");
Expand Down