fix: allow private lan cors origins

This commit is contained in:
Nico 2026-06-16 09:13:17 -07:00
parent 1c081038dd
commit 5ad3c46836
4 changed files with 95 additions and 9 deletions

View File

@ -24,7 +24,7 @@ If anything conflicts, follow **this** doc.
- `docker compose -f docker-compose.dev.yml up -d --build backend` - `docker compose -f docker-compose.dev.yml up -d --build backend`
- After backend env/CORS changes, recreate the backend service so `backend/.env` is reloaded: - After backend env/CORS changes, recreate the backend service so `backend/.env` is reloaded:
- `docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps backend` - `docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps backend`
- For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include the exact browser origin, for example `http://localhost:3010` and `http://127.0.0.1:3010`. - For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include exact loopback browser origins such as `http://localhost:3010` and `http://127.0.0.1:3010`; private LAN origins such as `http://192.168.7.45:3010` are allowed by the backend CORS helper for local device testing.
- Verify the restarted API with `GET http://127.0.0.1:5000/` and `GET http://127.0.0.1:5000/config`. - Verify the restarted API with `GET http://127.0.0.1:5000/` and `GET http://127.0.0.1:5000/config`.
- Do not print or commit real `.env` values while checking or updating local Docker env. - Do not print or commit real `.env` values while checking or updating local Docker env.

View File

@ -4,6 +4,7 @@ const path = require("path");
const User = require("./models/user.model"); const User = require("./models/user.model");
const requestIdMiddleware = require("./middleware/request-id"); const requestIdMiddleware = require("./middleware/request-id");
const { sendError } = require("./utils/http"); const { sendError } = require("./utils/http");
const { isAllowedCorsOrigin, parseAllowedOrigins } = require("./utils/cors-origins");
const app = express(); const app = express();
app.use(requestIdMiddleware); app.use(requestIdMiddleware);
@ -14,17 +15,11 @@ if (process.env.NODE_ENV !== "production") {
app.use("/test", express.static(path.join(__dirname, "public"))); app.use("/test", express.static(path.join(__dirname, "public")));
} }
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "") const allowedOrigins = parseAllowedOrigins(process.env.ALLOWED_ORIGINS || "");
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
app.use( app.use(
cors({ cors({
origin: function (origin, callback) { origin: function (origin, callback) {
if (!origin) return callback(null, true); if (isAllowedCorsOrigin(origin, allowedOrigins)) return callback(null, true);
if (allowedOrigins.includes(origin)) return callback(null, true);
if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
console.error(`CORS blocked origin: ${origin}`); console.error(`CORS blocked origin: ${origin}`);
callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`)); callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`));
}, },

View File

@ -0,0 +1,37 @@
const {
isAllowedCorsOrigin,
isPrivateLanHostname,
parseAllowedOrigins,
} = require("../utils/cors-origins");
describe("CORS origin helpers", () => {
test("parses configured allowed origins", () => {
expect(parseAllowedOrigins(" http://localhost:3010, http://127.0.0.1:3010 ,,")).toEqual([
"http://localhost:3010",
"http://127.0.0.1:3010",
]);
});
test("allows exact configured origins", () => {
const allowedOrigins = ["http://localhost:3010"];
expect(isAllowedCorsOrigin("http://localhost:3010", allowedOrigins)).toBe(true);
});
test("allows browser access from the local private network", () => {
expect(isAllowedCorsOrigin("http://192.168.7.45:3010", [])).toBe(true);
});
test("recognizes RFC1918 private IPv4 hostnames", () => {
expect(isPrivateLanHostname("10.1.2.3")).toBe(true);
expect(isPrivateLanHostname("172.16.0.1")).toBe(true);
expect(isPrivateLanHostname("172.31.255.254")).toBe(true);
expect(isPrivateLanHostname("192.168.7.45")).toBe(true);
});
test("rejects public and lookalike hostnames", () => {
expect(isAllowedCorsOrigin("http://192.168.7.45.example.test:3010", [])).toBe(false);
expect(isAllowedCorsOrigin("http://203.0.113.10:3010", [])).toBe(false);
expect(isAllowedCorsOrigin("ftp://192.168.7.45:3010", [])).toBe(false);
});
});

View File

@ -0,0 +1,54 @@
function parseAllowedOrigins(value = "") {
return String(value)
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
}
function parseIpv4(hostname) {
const parts = hostname.split(".");
if (parts.length !== 4) return null;
const octets = parts.map((part) => {
if (!/^\d{1,3}$/.test(part)) return null;
const value = Number(part);
return value >= 0 && value <= 255 ? value : null;
});
return octets.some((octet) => octet === null) ? null : octets;
}
function isPrivateLanHostname(hostname) {
const octets = parseIpv4(hostname);
if (!octets) return false;
const [first, second] = octets;
return (
first === 10 ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
);
}
function isAllowedCorsOrigin(origin, allowedOrigins = []) {
if (!origin) return true;
if (allowedOrigins.includes(origin)) return true;
try {
const url = new URL(origin);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return false;
}
return isPrivateLanHostname(url.hostname);
} catch {
return false;
}
}
module.exports = {
isAllowedCorsOrigin,
isPrivateLanHostname,
parseAllowedOrigins,
};