From b5339d1385f2cd0e3625d9c311a8e2c91b7e4ece Mon Sep 17 00:00:00 2001 From: Sumit Kumar Date: Mon, 16 Mar 2026 12:06:15 +0530 Subject: [PATCH 1/2] fix(dev): prevent X-Forwarded-For spoofing on Unix socket VFS access Deny VFS access when running on Unix socket since IP cannot be reliably determined. Never trust X-Forwarded-For header for this security-sensitive endpoint. Fixes #4103 --- src/dev/vfs.ts | 11 ++- test/unit/vfs-security.test.ts | 130 +++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 test/unit/vfs-security.test.ts diff --git a/src/dev/vfs.ts b/src/dev/vfs.ts index 4b4af3e64e..241605d03d 100644 --- a/src/dev/vfs.ts +++ b/src/dev/vfs.ts @@ -14,7 +14,16 @@ export function createVFSHandler(nitro: Nitro) { // Socket is readable/writable but has no port info socket?.readable && socket?.writable && !socket?.remotePort; - const ip = getRequestIP(event, { xForwardedFor: isUnixSocket }); + // Never trust X-Forwarded-For for VFS access - it's attacker-controlled + const ip = getRequestIP(event, { xForwardedFor: false }); + + // Deny access on Unix socket since IP cannot be reliably determined + if (isUnixSocket) { + throw new HTTPError({ + statusText: "VFS access not available on Unix socket", + status: 403, + }); + } const isLocalRequest = ip && /^::1$|^127\.\d+\.\d+\.\d+$/.test(ip); if (!isLocalRequest) { diff --git a/test/unit/vfs-security.test.ts b/test/unit/vfs-security.test.ts new file mode 100644 index 0000000000..1bbac5482e --- /dev/null +++ b/test/unit/vfs-security.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { mockEvent } from "h3"; +import { createVFSHandler } from "../../src/dev/vfs.ts"; +import type { Nitro } from "nitro/types"; + +// Mock a socket that appears to be a Unix socket +function createUnixSocketMock() { + return { + remoteAddress: undefined, + localAddress: undefined, + remotePort: undefined, + readable: true, + writable: true, + address: () => ({}), + } as any; +} + +// Mock a regular network socket +function createNetworkSocketMock(ip: string) { + return { + remoteAddress: ip, + localAddress: "127.0.0.1", + remotePort: 12345, + readable: true, + writable: true, + address: () => ({ address: ip, port: 12345 }), + } as any; +} + +// Create a mock event with socket +function createMockEvent( + socket: any, + headers: Record = {}, + params: Record = {} +) { + const event = mockEvent("http://localhost/_vfs", { + headers, + }); + // Attach the socket to the runtime.node.req (runtime is readonly, use defineProperty) + Object.defineProperty(event, "runtime", { + value: { + node: { + req: { socket }, + }, + }, + writable: false, + configurable: true, + }); + event.context.params = params; + return event; +} + +describe("VFS Security - X-Forwarded-For spoofing on Unix socket", () => { + it("should reject requests with spoofed X-Forwarded-For header on Unix socket", async () => { + // Create a mock Nitro instance with some VFS content + const mockNitro = { + options: { + rootDir: "/test/root", + }, + vfs: new Map([["/test/root/test.js", { render: () => "test content" }]]), + } as unknown as Nitro; + + const handler = createVFSHandler(mockNitro); + + // Create a mock Unix socket event with spoofed X-Forwarded-For + const unixSocket = createUnixSocketMock(); + const event = createMockEvent(unixSocket, { + "x-forwarded-for": "127.0.0.1", + }); + + // This should throw 403 because Unix socket access is denied + await expect(handler(event)).rejects.toThrow("VFS access not available on Unix socket"); + }); + + it("should reject requests without X-Forwarded-For on Unix socket", async () => { + const mockNitro = { + options: { + rootDir: "/test/root", + }, + vfs: new Map([["/test/root/test.js", { render: () => "test content" }]]), + } as unknown as Nitro; + + const handler = createVFSHandler(mockNitro); + + // Create a mock Unix socket event without X-Forwarded-For + const unixSocket = createUnixSocketMock(); + const event = createMockEvent(unixSocket, {}); + + // Should reject because Unix socket access is denied + await expect(handler(event)).rejects.toThrow("VFS access not available on Unix socket"); + }); + + it("should reject requests from non-local IP on regular network socket", async () => { + const mockNitro = { + options: { + rootDir: "/test/root", + }, + vfs: new Map([["/test/root/test.js", { render: () => "test content" }]]), + } as unknown as Nitro; + + const handler = createVFSHandler(mockNitro); + + // Create a mock network socket from external IP + const networkSocket = createNetworkSocketMock("192.168.1.100"); + const event = createMockEvent(networkSocket, {}); + + // Should reject because IP is not local + await expect(handler(event)).rejects.toThrow("Forbidden IP"); + }); + + it("should NOT trust X-Forwarded-For header on regular network socket", async () => { + const mockNitro = { + options: { + rootDir: "/test/root", + }, + vfs: new Map([["/test/root/test.js", { render: () => "test content" }]]), + } as unknown as Nitro; + + const handler = createVFSHandler(mockNitro); + + // Create a mock network socket from external IP with spoofed X-Forwarded-For + const networkSocket = createNetworkSocketMock("192.168.1.100"); + const event = createMockEvent(networkSocket, { + "x-forwarded-for": "127.0.0.1", + }); + + // Should reject because X-Forwarded-For is not trusted on network sockets + await expect(handler(event)).rejects.toThrow("Forbidden IP"); + }); +}); From 4c31de4620d3da6fa81f094359cbbc953971fcf9 Mon Sep 17 00:00:00 2001 From: Sumit Kumar Date: Mon, 16 Mar 2026 22:57:28 +0530 Subject: [PATCH 2/2] style: address coderabbit nitpicks - Remove explanatory inline comments in vfs.ts per repo style - Extract createMockNitro helper to deduplicate test fixtures --- src/dev/vfs.ts | 2 -- test/unit/vfs-security.test.ts | 36 +++++++++++----------------------- 2 files changed, 11 insertions(+), 27 deletions(-) diff --git a/src/dev/vfs.ts b/src/dev/vfs.ts index 241605d03d..c77aca4ac8 100644 --- a/src/dev/vfs.ts +++ b/src/dev/vfs.ts @@ -14,10 +14,8 @@ export function createVFSHandler(nitro: Nitro) { // Socket is readable/writable but has no port info socket?.readable && socket?.writable && !socket?.remotePort; - // Never trust X-Forwarded-For for VFS access - it's attacker-controlled const ip = getRequestIP(event, { xForwardedFor: false }); - // Deny access on Unix socket since IP cannot be reliably determined if (isUnixSocket) { throw new HTTPError({ statusText: "VFS access not available on Unix socket", diff --git a/test/unit/vfs-security.test.ts b/test/unit/vfs-security.test.ts index 1bbac5482e..ae5cf5d16e 100644 --- a/test/unit/vfs-security.test.ts +++ b/test/unit/vfs-security.test.ts @@ -3,6 +3,13 @@ import { mockEvent } from "h3"; import { createVFSHandler } from "../../src/dev/vfs.ts"; import type { Nitro } from "nitro/types"; +function createMockNitro() { + return { + options: { rootDir: "/test/root" }, + vfs: new Map([["/test/root/test.js", { render: () => "test content" }]]), + } as unknown as Nitro; +} + // Mock a socket that appears to be a Unix socket function createUnixSocketMock() { return { @@ -52,13 +59,7 @@ function createMockEvent( describe("VFS Security - X-Forwarded-For spoofing on Unix socket", () => { it("should reject requests with spoofed X-Forwarded-For header on Unix socket", async () => { - // Create a mock Nitro instance with some VFS content - const mockNitro = { - options: { - rootDir: "/test/root", - }, - vfs: new Map([["/test/root/test.js", { render: () => "test content" }]]), - } as unknown as Nitro; + const mockNitro = createMockNitro(); const handler = createVFSHandler(mockNitro); @@ -73,12 +74,7 @@ describe("VFS Security - X-Forwarded-For spoofing on Unix socket", () => { }); it("should reject requests without X-Forwarded-For on Unix socket", async () => { - const mockNitro = { - options: { - rootDir: "/test/root", - }, - vfs: new Map([["/test/root/test.js", { render: () => "test content" }]]), - } as unknown as Nitro; + const mockNitro = createMockNitro(); const handler = createVFSHandler(mockNitro); @@ -91,12 +87,7 @@ describe("VFS Security - X-Forwarded-For spoofing on Unix socket", () => { }); it("should reject requests from non-local IP on regular network socket", async () => { - const mockNitro = { - options: { - rootDir: "/test/root", - }, - vfs: new Map([["/test/root/test.js", { render: () => "test content" }]]), - } as unknown as Nitro; + const mockNitro = createMockNitro(); const handler = createVFSHandler(mockNitro); @@ -109,12 +100,7 @@ describe("VFS Security - X-Forwarded-For spoofing on Unix socket", () => { }); it("should NOT trust X-Forwarded-For header on regular network socket", async () => { - const mockNitro = { - options: { - rootDir: "/test/root", - }, - vfs: new Map([["/test/root/test.js", { render: () => "test content" }]]), - } as unknown as Nitro; + const mockNitro = createMockNitro(); const handler = createVFSHandler(mockNitro);