summaryrefslogtreecommitdiffhomepage
path: root/scripts/vite-network-guard.test.ts
blob: 8bef3970a4236460887941ec1b86cb0457518951 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Connect, Plugin, PreviewServer, ViteDevServer } from "vite";
import { describe, expect, it, vi } from "vitest";
import { applyViteNetworkPolicy, devNetworkGuard } from "./vite-network-guard";

const policy = {
  allowedHosts: new Set([
    "localhost",
    "127.0.0.1",
    "::1",
    "dev-node.tailnet.example",
    "192.0.2.10",
  ]),
  allowedAddresses: new Set(["127.0.0.1", "::1", "192.0.2.10"]),
};

function install(mode: "dev" | "preview") {
  let middleware: Connect.NextHandleFunction | undefined;
  const http = new EventEmitter();
  const upgrade = vi.fn();
  http.on("upgrade", upgrade);
  const server = {
    middlewares: {
      use: (handler: Connect.NextHandleFunction) => {
        middleware = handler;
      },
    },
    httpServer: http,
  };
  const plugin = devNetworkGuard(policy);
  const hook = mode === "dev" ? plugin.configureServer : plugin.configurePreviewServer;
  if (typeof hook !== "function") throw new Error(`Missing ${mode} guard hook`);
  // Hooks only need the injected middleware stack and HTTP event emitter.
  hook.call({} as never, server as unknown as ViteDevServer & PreviewServer);
  if (!middleware) throw new Error(`Missing ${mode} middleware`);
  return { middleware, http, upgrade };
}

function request(localAddress: string | undefined, host?: string, origin?: string) {
  return { socket: { localAddress }, headers: { host, origin } } as IncomingMessage;
}

const cases = [
  ["loopback", "127.0.0.1", "localhost", undefined, 200],
  ["IPv6 loopback", "::1", "[::1]:34204", undefined, 200],
  ["mapped address / canonical Host", "::ffff:127.0.0.1", "LOCALHOST.:34204", undefined, 200],
  [
    "tailnet",
    "192.0.2.10",
    "dev-node.tailnet.example",
    "https://dev-node.tailnet.example:34204",
    200,
  ],
  ["unselected interface with trusted Host", "172.17.0.1", "localhost", undefined, 403],
  ["missing interface", undefined, "localhost", undefined, 403],
  ["arbitrary IP Host", "127.0.0.1", "127.0.0.2", undefined, 421],
  ["rebinding Host", "127.0.0.1", "evil.example", undefined, 421],
  ["missing Host", "127.0.0.1", undefined, undefined, 421],
  ["disallowed Origin", "127.0.0.1", "localhost", "https://evil.example", 403],
  ["opaque Origin", "127.0.0.1", "localhost", "null", 403],
  ["Host before Origin", "127.0.0.1", "evil.example", "null", 421],
] as const;

describe.each(["dev", "preview"] as const)("%s HTTP guard", (mode) => {
  it.each(cases)("%s", (_label, local, host, origin, status) => {
    const { middleware } = install(mode);
    const req = request(local, host, origin);
    const writeHead = vi.fn();
    const end = vi.fn();
    const next = vi.fn();
    middleware(req, { writeHead, end } as unknown as ServerResponse, next);
    if (status === 200) {
      expect(next).toHaveBeenCalledExactlyOnceWith();
      expect(writeHead).not.toHaveBeenCalled();
      if (host === "LOCALHOST.:34204") expect(req.headers.host).toBe("localhost:34204");
    } else {
      expect(writeHead).toHaveBeenCalledWith(status, { "Content-Type": "text/plain" });
      expect(end).toHaveBeenCalledOnce();
      expect(next).not.toHaveBeenCalled(); // In particular, cannot reach CORS/static serving.
    }
  });
});

describe("dev upgrades remain protected", () => {
  it.each(cases)("%s", (_label, local, host, origin, status) => {
    const { http, upgrade } = install("dev");
    const end = vi.fn();
    http.emit("upgrade", request(local, host, origin), { end }, Buffer.alloc(0));
    if (status === 200) {
      expect(upgrade).toHaveBeenCalledOnce();
      expect(end).not.toHaveBeenCalled();
    } else {
      expect(upgrade).not.toHaveBeenCalled();
      expect(end).toHaveBeenCalledWith(expect.stringContaining(`HTTP/1.1 ${status}`));
    }
  });
});

it("sets explicit allowlists and guarded CORS for preview as well as dev", () => {
  const config = applyViteNetworkPolicy(
    {
      server: { host: true, port: 34204 },
      preview: { host: true, port: 34204, cors: true, allowedHosts: true },
    },
    policy,
  );
  for (const serving of [config.server, config.preview]) {
    expect(serving?.allowedHosts).toEqual([...policy.allowedHosts]);
    expect(serving?.cors).toEqual({ origin: true });
    expect(serving?.host).toBe(true);
    expect(serving?.port).toBe(34204);
  }
  const plugin = config.plugins?.[0] as Plugin;
  expect(plugin.configureServer).toBeTypeOf("function");
  expect(plugin.configurePreviewServer).toBeTypeOf("function");
});