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
102 changes: 102 additions & 0 deletions src/server/Cloudflare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import net from "net";

export const CLOUDFLARE_IPV4_CIDRS = [
"173.245.48.0/20",
"103.21.244.0/22",
"103.22.200.0/22",
"103.31.4.0/22",
"141.101.64.0/18",
"108.162.192.0/18",
"190.93.240.0/20",
"188.114.96.0/20",
"197.234.240.0/22",
"198.41.128.0/17",
"162.158.0.0/15",
"104.16.0.0/13",
"104.24.0.0/14",
"172.64.0.0/13",
"131.0.72.0/22",
];

export const CLOUDFLARE_IPV6_CIDRS = [
"2400:cb00::/32",
"2606:4700::/32",
"2803:f800::/32",
"2405:b500::/32",
"2405:8100::/32",
"2a06:98c0::/29",
"2c0f:f248::/32",
];

function ip4ToInt(ip: string): number {
const parts = ip.split(".").map((part) => parseInt(part, 10));
return (
((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]
);
}

function checkIPv4InCIDR(ip: string, cidr: string): boolean {
const [range, bitsStr] = cidr.split("/");
const bits = parseInt(bitsStr, 10);
const ipInt = ip4ToInt(ip);
const rangeInt = ip4ToInt(range);
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
return (ipInt & mask) === (rangeInt & mask);
}

function ip6ToBigInt(ip: string): bigint {
let fullIp = ip;
if (ip.includes("::")) {
const parts = ip.split("::");
const left = parts[0] ? parts[0].split(":") : [];
const right = parts[1] ? parts[1].split(":") : [];
const missingCount = 8 - (left.length + right.length);
const middle = Array(missingCount).fill("0");
fullIp = [...left, ...middle, ...right].join(":");
}
const parts = fullIp
.split(":")
.map((part) => (part === "" ? 0n : BigInt(parseInt(part, 16))));
let result = 0n;
for (const part of parts) {
result = (result << 16n) + part;
}
return result;
}

function checkIPv6InCIDR(ip: string, cidr: string): boolean {
const [range, bitsStr] = cidr.split("/");
const bits = parseInt(bitsStr, 10);
const ipVal = ip6ToBigInt(ip);
const rangeVal = ip6ToBigInt(range);
const mask =
bits === 0 ? 0n : ((1n << 128n) - 1n) ^ ((1n << BigInt(128 - bits)) - 1n);
return (ipVal & mask) === (rangeVal & mask);
}

export function isCloudflareIp(ip: string): boolean {
if (!ip) return false;
// Convert mapped IPv4 address (e.g. ::ffff:173.245.48.5) to standard IPv4
if (ip.startsWith("::ffff:")) {
ip = ip.substring(7);
}
if (net.isIPv4(ip)) {
return CLOUDFLARE_IPV4_CIDRS.some((cidr) => checkIPv4InCIDR(ip, cidr));
}
if (net.isIPv6(ip)) {
return CLOUDFLARE_IPV6_CIDRS.some((cidr) => checkIPv6InCIDR(ip, cidr));
}
return false;
}

export function isLoopbackIp(ip: string): boolean {
if (!ip) return false;
if (ip.startsWith("::ffff:")) {
ip = ip.substring(7);
}
return ip === "127.0.0.1" || ip === "::1" || ip === "localhost";
}

export function isCloudflareOrLoopbackIp(ip: string): boolean {
return isLoopbackIp(ip) || isCloudflareIp(ip);
}
28 changes: 28 additions & 0 deletions src/server/Master.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { renderAppShell } from "./RenderHtml";
import { ServerEnv } from "./ServerEnv";
import { applyStaticAssetCacheControl } from "./StaticAssetCache";

import { isCloudflareOrLoopbackIp } from "./Cloudflare";

const playlist = new MapPlaylist();
let lobbyService: MasterLobbyService;

Expand Down Expand Up @@ -57,6 +59,32 @@ app.use(
);

app.set("trust proxy", 3);

if (ServerEnv.env() === GameEnv.Prod || ServerEnv.env() === GameEnv.Preprod) {
app.use((req, res, next) => {
const clientIp = req.socket.remoteAddress ?? "";
if (!isCloudflareOrLoopbackIp(clientIp)) {
log.warn(
`Bypassed Cloudflare proxy. Direct connection from non-Cloudflare IP: ${clientIp}`,
);
return res.status(403).send("Forbidden: Direct IP access is blocked.");
}

const host = req.headers.host ?? "";
const isIpHost = /^[0-9.:]+$/.test(host);
if (
isIpHost &&
!clientIp.includes("127.0.0.1") &&
!clientIp.includes("::1")
) {
log.warn(`Bypassed Cloudflare proxy. Host header was an IP: ${host}`);
return res.status(403).send("Forbidden: Direct IP access is blocked.");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

next();
});
}

app.use(
rateLimit({
windowMs: 1000, // 1 second
Expand Down
45 changes: 44 additions & 1 deletion src/server/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { registerGamePreviewRoute } from "./GamePreviewRoute";
import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger";

import { isCloudflareOrLoopbackIp } from "./Cloudflare";
import { MapPlaylist } from "./MapPlaylist";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { startPolling } from "./PollingLoop";
Expand Down Expand Up @@ -103,6 +104,32 @@ export async function startWorker() {
});

app.set("trust proxy", 3);

if (ServerEnv.env() === GameEnv.Prod || ServerEnv.env() === GameEnv.Preprod) {
app.use((req, res, next) => {
const clientIp = req.socket.remoteAddress ?? "";
if (!isCloudflareOrLoopbackIp(clientIp)) {
log.warn(
`Bypassed Cloudflare proxy. Direct connection from non-Cloudflare IP: ${clientIp}`,
);
return res.status(403).send("Forbidden: Direct IP access is blocked.");
}

const host = req.headers.host ?? "";
const isIpHost = /^[0-9.:]+$/.test(host);
if (
isIpHost &&
!clientIp.includes("127.0.0.1") &&
!clientIp.includes("::1")
) {
log.warn(`Bypassed Cloudflare proxy. Host header was an IP: ${host}`);
return res.status(403).send("Forbidden: Direct IP access is blocked.");
}

next();
});
}

app.use(compression());

app.use(
Expand Down Expand Up @@ -277,8 +304,24 @@ export async function startWorker() {
}
});

// WebSocket handling
wss.on("connection", (ws: WebSocket, req) => {
if (
ServerEnv.env() === GameEnv.Prod ||
ServerEnv.env() === GameEnv.Preprod
) {
const host = req.headers.host ?? "";
const isIpHost = /^[0-9.:]+$/.test(host);
const hasCfHeader = Boolean(req.headers["cf-connecting-ip"]);

if (isIpHost || !hasCfHeader) {
log.warn(
`WebSocket connection bypassed Cloudflare proxy. Host: ${host}`,
);
ws.close(1002, "Forbidden");
return;
}
}

ws.on("message", async (message: string) => {
const ip = getClientIp(req);

Expand Down
33 changes: 33 additions & 0 deletions src/server/WorkerLobbyService.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import http from "http";
import { WebSocket, WebSocketServer } from "ws";
import { GameEnv } from "../core/configuration/Config";
import { PublicGameInfo, PublicGames } from "../core/Schemas";
import { isCloudflareOrLoopbackIp } from "./Cloudflare";
import { GameManager } from "./GameManager";
import {
MasterMessageSchema,
WorkerLobbyList,
WorkerReady,
} from "./IPCBridgeSchema";
import { logger } from "./Logger";
import { ServerEnv } from "./ServerEnv";

export class WorkerLobbyService {
private readonly lobbiesWss: WebSocketServer;
Expand Down Expand Up @@ -100,6 +103,36 @@ export class WorkerLobbyService {

private setupUpgradeHandler() {
this.server.on("upgrade", (request, socket, head) => {
if (
ServerEnv.env() === GameEnv.Prod ||
ServerEnv.env() === GameEnv.Preprod
) {
const clientIp = (socket as any).remoteAddress ?? "";
if (!isCloudflareOrLoopbackIp(clientIp)) {
this.log.warn(
`WebSocket upgrade rejected: Bypassed Cloudflare proxy. Remote IP: ${clientIp}`,
);
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
return;
}

const host = request.headers.host ?? "";
const isIpHost = /^[0-9.:]+$/.test(host);
if (
isIpHost &&
!clientIp.includes("127.0.0.1") &&
!clientIp.includes("::1")
) {
this.log.warn(
`WebSocket upgrade rejected: Bypassed Cloudflare proxy. Host header was an IP: ${host}`,
);
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
return;
}
}

const pathname = request.url ?? "";
if (pathname === "/lobbies" || pathname.endsWith("/lobbies")) {
this.lobbiesWss.handleUpgrade(request, socket, head, (ws) => {
Expand Down
60 changes: 60 additions & 0 deletions tests/server/Cloudflare.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, test } from "vitest";
import {
isCloudflareIp,
isCloudflareOrLoopbackIp,
isLoopbackIp,
} from "../../src/server/Cloudflare";

describe("Cloudflare IP Validation", () => {
describe("isLoopbackIp", () => {
test("identifies standard loopback IPs", () => {
expect(isLoopbackIp("127.0.0.1")).toBe(true);
expect(isLoopbackIp("::1")).toBe(true);
expect(isLoopbackIp("::ffff:127.0.0.1")).toBe(true);
expect(isLoopbackIp("localhost")).toBe(true);
});

test("returns false for non-loopback IPs", () => {
expect(isLoopbackIp("8.8.8.8")).toBe(false);
expect(isLoopbackIp("192.168.1.1")).toBe(false);
expect(isLoopbackIp("")).toBe(false);
});
});

describe("isCloudflareIp", () => {
test("returns true for valid Cloudflare IPv4 addresses", () => {
expect(isCloudflareIp("173.245.48.5")).toBe(true);
expect(isCloudflareIp("::ffff:173.245.48.5")).toBe(true);
expect(isCloudflareIp("103.21.244.1")).toBe(true);
expect(isCloudflareIp("104.16.0.2")).toBe(true);
expect(isCloudflareIp("172.64.0.9")).toBe(true);
});

test("returns true for valid Cloudflare IPv6 addresses", () => {
expect(isCloudflareIp("2400:cb00:0000:0000:0000:0000:0000:0001")).toBe(
true,
);
expect(isCloudflareIp("2400:cb00::1")).toBe(true);
expect(isCloudflareIp("2606:4700::ffff")).toBe(true);
});

test("returns false for non-Cloudflare IPs", () => {
expect(isCloudflareIp("8.8.8.8")).toBe(false);
expect(isCloudflareIp("1.1.1.1")).toBe(false);
expect(isCloudflareIp("192.168.1.1")).toBe(false);
expect(isCloudflareIp("2001:4860:4860::8888")).toBe(false);
expect(isCloudflareIp("")).toBe(false);
});
});

describe("isCloudflareOrLoopbackIp", () => {
test("returns true for both loopback and Cloudflare IPs", () => {
expect(isCloudflareOrLoopbackIp("127.0.0.1")).toBe(true);
expect(isCloudflareOrLoopbackIp("173.245.48.5")).toBe(true);
});

test("returns false for regular external IPs", () => {
expect(isCloudflareOrLoopbackIp("8.8.8.8")).toBe(false);
});
});
});
Loading