diff --git a/docs/collaboration.md b/docs/collaboration.md index ca254aba7..dc8cf60b5 100644 --- a/docs/collaboration.md +++ b/docs/collaboration.md @@ -257,7 +257,13 @@ ephemeral and never written to a project file. > **Operator note:** `POST /sessions` validates the request `Origin` (or > `Referer`) against `ALLOWED_ORIGINS` via `isAllowedOrigin` (defaults to the -> app's own domains plus `localhost` for development) as browser-origin filtering +> hosted origins (`geolibre.app`, `web.geolibre.app`, its legacy +> `viewer.geolibre.app` alias, `studio.geolibre.app`, and +> `collab.geolibre.app`), single-label HTTPS deployment hosts under +> `*.geolibre-preview.pages.dev`, loopback hosts (`localhost` and +> `127.0.0.1`), and `tauri://localhost`). Nested or custom-port preview hosts +> and look-alike domains are rejected; the shared `opengeos.org` GitHub Pages +> preview origin is deliberately not trusted) as browser-origin filtering > and defense-in-depth (not authentication or a general server-side access gate) > and enforces a per-IP `checkRateLimit` (10 requests / 60 s). `Access-Control-Allow-Origin: *` is > still sent on responses so non-browser clients (e.g. Tauri) are not blocked by diff --git a/workers/collab-node/src/server.ts b/workers/collab-node/src/server.ts index 2eef822d4..9a19b52e1 100644 --- a/workers/collab-node/src/server.ts +++ b/workers/collab-node/src/server.ts @@ -65,6 +65,9 @@ function isAllowedOrigin( .filter(Boolean) : [ "https://geolibre.app", + "https://web.geolibre.app", + "https://viewer.geolibre.app", + "https://studio.geolibre.app", "https://collab.geolibre.app", "http://localhost", "http://127.0.0.1", @@ -74,7 +77,19 @@ function isAllowedOrigin( try { const originUrl = new URL(originHeader); const host = originUrl.hostname; - if (host === "localhost" || host === "127.0.0.1" || host.endsWith(".localhost")) return true; + if (!envAllowed) { + if (host === "localhost" || host === "127.0.0.1" || host.endsWith(".localhost")) return true; + const previewSuffix = ".geolibre-preview.pages.dev"; + const previewLabel = host.endsWith(previewSuffix) ? host.slice(0, -previewSuffix.length) : ""; + if ( + originUrl.protocol === "https:" && + !originUrl.port && + previewLabel && + !previewLabel.includes(".") + ) { + return true; + } + } return allowedList.some((allowed) => { if (allowed === "*") return true; try { diff --git a/workers/collab-node/test/relay.test.ts b/workers/collab-node/test/relay.test.ts index 50e14b1c9..621405fdc 100644 --- a/workers/collab-node/test/relay.test.ts +++ b/workers/collab-node/test/relay.test.ts @@ -101,6 +101,72 @@ describe("Node collaboration relay", () => { await assert.rejects(connect(http, "NOTFOUND"), /Unexpected server response: 404/); }); + it("allows the hosted GeoLibre web origins to create sessions", async () => { + const { http } = await start(); + + for (const origin of [ + "https://geolibre.app", + "https://web.geolibre.app", + "https://viewer.geolibre.app", + "https://studio.geolibre.app", + "https://50e58010.geolibre-preview.pages.dev", + ]) { + const response = await fetch(`${http}/sessions`, { + method: "POST", + headers: { origin }, + }); + assert.equal(response.status, 200, `${origin} should be allowed`); + } + + const rejected = await fetch(`${http}/sessions`, { + method: "POST", + headers: { origin: "https://web.geolibre.app.example.com" }, + }); + assert.equal(rejected.status, 403); + + const rejectedPreviewLookalike = await fetch(`${http}/sessions`, { + method: "POST", + headers: { origin: "https://preview.geolibre-preview.pages.dev.example.com" }, + }); + assert.equal(rejectedPreviewLookalike.status, 403); + + for (const origin of [ + "https://opengeos.org", + "https://a.b.geolibre-preview.pages.dev", + "https://preview.geolibre-preview.pages.dev:8443", + ]) { + const response = await fetch(`${http}/sessions`, { + method: "POST", + headers: { origin }, + }); + assert.equal(response.status, 403, `${origin} should be rejected`); + } + }); + + it("makes a configured origin allowlist authoritative", async () => { + const { http } = await start(); + const previous = process.env.ALLOWED_ORIGINS; + process.env.ALLOWED_ORIGINS = "https://allowed.example"; + try { + const allowed = await fetch(`${http}/sessions`, { + method: "POST", + headers: { origin: "https://allowed.example" }, + }); + assert.equal(allowed.status, 200); + + for (const origin of ["http://localhost:5173", "https://pr-1.geolibre-preview.pages.dev"]) { + const response = await fetch(`${http}/sessions`, { + method: "POST", + headers: { origin }, + }); + assert.equal(response.status, 403, `${origin} should require explicit configuration`); + } + } finally { + if (previous === undefined) delete process.env.ALLOWED_ORIGINS; + else process.env.ALLOWED_ORIGINS = previous; + } + }); + it("rejects an oversized session-create body by declared length and by count", async () => { const { http } = await start(); diff --git a/workers/collab/src/index.ts b/workers/collab/src/index.ts index e34f9e415..ec55b23c9 100644 --- a/workers/collab/src/index.ts +++ b/workers/collab/src/index.ts @@ -51,6 +51,9 @@ function isAllowedOrigin(originHeader: string | null, envAllowed?: string): bool .filter(Boolean) : [ "https://geolibre.app", + "https://web.geolibre.app", + "https://viewer.geolibre.app", + "https://studio.geolibre.app", "https://collab.geolibre.app", "http://localhost", "http://127.0.0.1", @@ -60,7 +63,19 @@ function isAllowedOrigin(originHeader: string | null, envAllowed?: string): bool try { const originUrl = new URL(originHeader); const host = originUrl.hostname; - if (host === "localhost" || host === "127.0.0.1" || host.endsWith(".localhost")) return true; + if (!envAllowed) { + if (host === "localhost" || host === "127.0.0.1" || host.endsWith(".localhost")) return true; + const previewSuffix = ".geolibre-preview.pages.dev"; + const previewLabel = host.endsWith(previewSuffix) ? host.slice(0, -previewSuffix.length) : ""; + if ( + originUrl.protocol === "https:" && + !originUrl.port && + previewLabel && + !previewLabel.includes(".") + ) { + return true; + } + } return allowedList.some((allowed) => { if (allowed === "*") return true; try {