Skip to content

Commit efc2e63

Browse files
fix(server): validate request origins (#4234)
This change validates request origins for HTTP and Socket.IO connections. It keeps same-origin browser access working, preserves existing IP whitelist behavior, and continues to support clients without an Origin header. Cross-origin requests are rejected by default.
1 parent 00350ee commit efc2e63

3 files changed

Lines changed: 96 additions & 41 deletions

File tree

js/ip_access_control.js

Lines changed: 50 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -55,54 +55,71 @@ function resolveClientIp (req) {
5555
}
5656

5757
/**
58-
* Creates an Express middleware for IP whitelisting
58+
* Checks whether a browser Origin matches the host serving the mirror.
59+
* Non-browser clients (Electron clientonly, curl, node_helpers) send no Origin and are allowed.
60+
* @param {object} req - Incoming request object
61+
* @returns {boolean} True if the origin is same-host or absent
62+
*/
63+
function isSameOrigin (req) {
64+
const origin = req.headers?.origin;
65+
if (!origin) return true;
66+
67+
const host = req.headers?.host;
68+
if (!host) return false;
69+
70+
try {
71+
return new URL(origin).host === new URL(`http://${host}`).host;
72+
} catch {
73+
return false;
74+
}
75+
}
76+
77+
/**
78+
* Determines why a request is denied, or null if it is allowed.
79+
* Enforces same-origin first (CSRF protection), then the optional IP whitelist.
80+
* @param {object} req - Incoming Express or Socket.IO request
81+
* @param {string[]} whitelist - Array of allowed IP addresses or CIDR ranges (empty = any IP)
82+
* @returns {string|null} A human-readable denial reason, or null when allowed
83+
*/
84+
function accessDenialReason (req, whitelist) {
85+
// Strip control characters from the attacker-controlled Origin header before logging it
86+
if (!isSameOrigin(req)) return `Origin ${String(req.headers?.origin).replace(/[\r\n]/g, "")} is not allowed`;
87+
88+
if (Array.isArray(whitelist) && whitelist.length > 0) {
89+
const clientIp = resolveClientIp(req);
90+
if (!isAllowed(clientIp, whitelist)) return `IP ${clientIp} is not allowed`;
91+
}
92+
93+
return null;
94+
}
95+
96+
/**
97+
* Creates an Express middleware enforcing same-origin and the IP whitelist.
5998
* @param {string[]} whitelist - Array of allowed IP addresses or CIDR ranges
6099
* @returns {import("express").RequestHandler} Express middleware function
61100
*/
62101
function ipAccessControl (whitelist) {
63-
// Empty whitelist means allow all
64-
if (!Array.isArray(whitelist) || whitelist.length === 0) {
65-
return function (req, res, next) {
66-
res.header("Access-Control-Allow-Origin", "*");
67-
next();
68-
};
69-
}
70-
71102
return function (req, res, next) {
72-
const clientIp = resolveClientIp(req);
103+
const reason = accessDenialReason(req, whitelist);
104+
if (!reason) return next();
73105

74-
if (isAllowed(clientIp, whitelist)) {
75-
res.header("Access-Control-Allow-Origin", "*");
76-
next();
77-
} else {
78-
Log.warn(`IP ${clientIp} is not allowed to access the mirror`);
79-
res.status(403).send("This device is not allowed to access your mirror. <br> Please check your config.js or config.js.sample to change this.");
80-
}
106+
Log.warn(`${reason} to access the mirror`);
107+
res.status(403).send("This device is not allowed to access your mirror. <br> Please check your config.js or config.js.sample to change this.");
81108
};
82109
}
83110

84111
/**
85-
* Creates a Socket.IO `allowRequest` handler that enforces the same IP whitelist as the HTTP middleware.
86-
* This closes the gap where Socket.IO handshakes bypassed the Express-only `ipAccessControl` middleware.
112+
* Creates a Socket.IO `allowRequest` handler enforcing the same rules as the HTTP middleware.
87113
* @param {string[]} whitelist - Array of allowed IP addresses or CIDR ranges
88114
* @returns {(req: object, callback: (err: string | null, success: boolean) => void) => void} Socket.IO allowRequest handler
89115
*/
90116
function socketIpAccessControl (whitelist) {
91-
// Empty whitelist means allow all
92-
if (!Array.isArray(whitelist) || whitelist.length === 0) {
93-
return function (req, callback) {
94-
callback(null, true); // allow the connection
95-
};
96-
}
97-
98117
return function (req, callback) {
99-
const clientIp = resolveClientIp(req);
100-
if (isAllowed(clientIp, whitelist)) {
101-
callback(null, true); // allow the connection
102-
} else {
103-
Log.warn(`IP ${clientIp} is not allowed to connect to the mirror socket`);
104-
callback("This device is not allowed to access your mirror.", false);
105-
}
118+
const reason = accessDenialReason(req, whitelist);
119+
if (!reason) return callback(null, true);
120+
121+
Log.warn(`${reason} to connect to the mirror socket`);
122+
callback("This device is not allowed to access your mirror.", false);
106123
};
107124
}
108125

js/server.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,6 @@ function Server (configObj) {
4242
}
4343
const io = socketio(server, {
4444
allowRequest: socketIpAccessControl(config.ipWhitelist),
45-
cors: {
46-
origin: /.*$/,
47-
credentials: true
48-
},
4945
allowEIO3: true,
5046
pingInterval: 120000, // server → client ping every 2 mins
5147
pingTimeout: 120000 // wait up to 2 mins for client pong

tests/unit/functions/ip_access_control_spec.js

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@ import { ipAccessControl, socketIpAccessControl } from "../../../js/ip_access_co
44

55
/**
66
* Creates a minimal Express-like response mock used by the middleware tests.
7-
* @returns {{ header: ReturnType<typeof vi.fn>, status: ReturnType<typeof vi.fn>, send: ReturnType<typeof vi.fn> }} Mock response object.
7+
* @returns {{ status: ReturnType<typeof vi.fn>, send: ReturnType<typeof vi.fn> }} Mock response object.
88
*/
99
function createResponseMock () {
1010
return {
11-
header: vi.fn(),
1211
status: vi.fn(function () {
1312
return this;
1413
}),
@@ -47,14 +46,44 @@ describe("ip_access_control", () => {
4746
expect(next).not.toHaveBeenCalled();
4847
expect(res.status).toHaveBeenCalledWith(403);
4948
});
49+
50+
it("rejects cross-origin HTTP requests even when the IP matches", () => {
51+
const middleware = ipAccessControl(["203.0.113.10"]);
52+
const req = {
53+
socket: { remoteAddress: "203.0.113.10" },
54+
headers: { host: "localhost:8080", origin: "https://evil.example" }
55+
};
56+
const res = createResponseMock();
57+
const next = vi.fn();
58+
59+
middleware(req, res, next);
60+
61+
expect(next).not.toHaveBeenCalled();
62+
expect(res.status).toHaveBeenCalledWith(403);
63+
});
64+
65+
it("rejects cross-origin HTTP requests even with an empty whitelist", () => {
66+
const middleware = ipAccessControl([]);
67+
const req = {
68+
socket: { remoteAddress: "198.51.100.7" },
69+
headers: { host: "localhost:8080", origin: "https://evil.example" }
70+
};
71+
const res = createResponseMock();
72+
const next = vi.fn();
73+
74+
middleware(req, res, next);
75+
76+
expect(next).not.toHaveBeenCalled();
77+
expect(res.status).toHaveBeenCalledWith(403);
78+
});
5079
});
5180

5281
describe("socketIpAccessControl", () => {
5382
it("accepts socket handshake using forwarded client IP when direct peer is loopback", () => {
5483
const allowRequest = socketIpAccessControl(["203.0.113.10"]);
5584
const req = {
5685
socket: { remoteAddress: "::1" },
57-
headers: { "x-forwarded-for": "203.0.113.10, 10.0.0.2" }
86+
headers: { host: "localhost:8080", "x-forwarded-for": "203.0.113.10, 10.0.0.2", origin: "http://localhost:8080" }
5887
};
5988
const callback = vi.fn();
6089

@@ -67,7 +96,20 @@ describe("ip_access_control", () => {
6796
const allowRequest = socketIpAccessControl(["203.0.113.10"]);
6897
const req = {
6998
socket: { remoteAddress: "198.51.100.7" },
70-
headers: { "x-forwarded-for": "203.0.113.10" }
99+
headers: { host: "localhost:8080", "x-forwarded-for": "203.0.113.10", origin: "http://localhost:8080" }
100+
};
101+
const callback = vi.fn();
102+
103+
allowRequest(req, callback);
104+
105+
expect(callback).toHaveBeenCalledWith("This device is not allowed to access your mirror.", false);
106+
});
107+
108+
it("rejects cross-origin socket handshakes even when the IP matches", () => {
109+
const allowRequest = socketIpAccessControl(["203.0.113.10"]);
110+
const req = {
111+
socket: { remoteAddress: "203.0.113.10" },
112+
headers: { host: "localhost:8080", origin: "https://evil.example" }
71113
};
72114
const callback = vi.fn();
73115

0 commit comments

Comments
 (0)