diff --git a/PROJECT_INSTRUCTIONS.md b/PROJECT_INSTRUCTIONS.md index a19eb2f..ceb010b 100644 --- a/PROJECT_INSTRUCTIONS.md +++ b/PROJECT_INSTRUCTIONS.md @@ -24,7 +24,7 @@ If anything conflicts, follow **this** doc. - `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: - `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`. - Do not print or commit real `.env` values while checking or updating local Docker env. diff --git a/backend/app.js b/backend/app.js index c6a97f1..cea98ba 100644 --- a/backend/app.js +++ b/backend/app.js @@ -4,6 +4,7 @@ const path = require("path"); const User = require("./models/user.model"); const requestIdMiddleware = require("./middleware/request-id"); const { sendError } = require("./utils/http"); +const { isAllowedCorsOrigin, parseAllowedOrigins } = require("./utils/cors-origins"); const app = express(); app.use(requestIdMiddleware); @@ -14,17 +15,11 @@ if (process.env.NODE_ENV !== "production") { app.use("/test", express.static(path.join(__dirname, "public"))); } -const allowedOrigins = (process.env.ALLOWED_ORIGINS || "") - .split(",") - .map((origin) => origin.trim()) - .filter(Boolean); +const allowedOrigins = parseAllowedOrigins(process.env.ALLOWED_ORIGINS || ""); app.use( cors({ origin: function (origin, callback) { - if (!origin) 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); + if (isAllowedCorsOrigin(origin, allowedOrigins)) return callback(null, true); console.error(`CORS blocked origin: ${origin}`); callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`)); }, diff --git a/backend/tests/cors-origins.test.js b/backend/tests/cors-origins.test.js new file mode 100644 index 0000000..9ad4bbc --- /dev/null +++ b/backend/tests/cors-origins.test.js @@ -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); + }); +}); diff --git a/backend/utils/cors-origins.js b/backend/utils/cors-origins.js new file mode 100644 index 0000000..42c4a74 --- /dev/null +++ b/backend/utils/cors-origins.js @@ -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, +};