From ecaba3a65308902ac4e3c0a03622102a95533265 Mon Sep 17 00:00:00 2001 From: giswqs Date: Wed, 19 Aug 2026 22:30:02 -0400 Subject: [PATCH 1/4] fix(collab): allow hosted app origins The production web and studio hosts were missing from the relay defaults, causing session creation to fail with HTTP 403. Keep both relay implementations aligned and cover the hosted origins with a regression test. --- docs/collaboration.md | 4 +++- workers/collab-node/src/server.ts | 3 +++ workers/collab-node/test/relay.test.ts | 23 +++++++++++++++++++++++ workers/collab/src/index.ts | 3 +++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/collaboration.md b/docs/collaboration.md index ca254aba74..fa1a7d1dcc 100644 --- a/docs/collaboration.md +++ b/docs/collaboration.md @@ -257,7 +257,9 @@ 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 app origins (`geolibre.app`, `web.geolibre.app`, its legacy +> `viewer.geolibre.app` alias, and `studio.geolibre.app`) plus `localhost` for +> development) 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 2eef822d4c..86265ffd6f 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", diff --git a/workers/collab-node/test/relay.test.ts b/workers/collab-node/test/relay.test.ts index 50e14b1c94..cb49a9e675 100644 --- a/workers/collab-node/test/relay.test.ts +++ b/workers/collab-node/test/relay.test.ts @@ -101,6 +101,29 @@ 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", + ]) { + 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); + }); + 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 e34f9e4158..a45730f9cd 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", From baca4215bba8c37f245e45bf2668a793056c8657 Mon Sep 17 00:00:00 2001 From: giswqs Date: Wed, 19 Aug 2026 22:42:24 -0400 Subject: [PATCH 2/4] fix(collab): allow deployment preview origins PR previews run on the project-owned Cloudflare Pages hostname and opengeos.org, so the production relay must accept those origins too. Keep custom operator allowlists authoritative and reject look-alike preview domains. --- docs/collaboration.md | 3 ++- workers/collab-node/src/server.ts | 7 +++++++ workers/collab-node/test/relay.test.ts | 8 ++++++++ workers/collab/src/index.ts | 7 +++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/collaboration.md b/docs/collaboration.md index fa1a7d1dcc..ca84fb3447 100644 --- a/docs/collaboration.md +++ b/docs/collaboration.md @@ -259,7 +259,8 @@ ephemeral and never written to a project file. > `Referer`) against `ALLOWED_ORIGINS` via `isAllowedOrigin` (defaults to the > hosted app origins (`geolibre.app`, `web.geolibre.app`, its legacy > `viewer.geolibre.app` alias, and `studio.geolibre.app`) plus `localhost` for -> development) as browser-origin filtering +> development and the project-owned Cloudflare/GitHub Pages preview origins) 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 86265ffd6f..74e95b05f5 100644 --- a/workers/collab-node/src/server.ts +++ b/workers/collab-node/src/server.ts @@ -78,6 +78,13 @@ function isAllowedOrigin( const originUrl = new URL(originHeader); const host = originUrl.hostname; if (host === "localhost" || host === "127.0.0.1" || host.endsWith(".localhost")) return true; + if ( + !envAllowed && + originUrl.protocol === "https:" && + (host === "opengeos.org" || host.endsWith(".geolibre-preview.pages.dev")) + ) { + 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 cb49a9e675..e6b3447d31 100644 --- a/workers/collab-node/test/relay.test.ts +++ b/workers/collab-node/test/relay.test.ts @@ -109,6 +109,8 @@ describe("Node collaboration relay", () => { "https://web.geolibre.app", "https://viewer.geolibre.app", "https://studio.geolibre.app", + "https://opengeos.org", + "https://50e58010.geolibre-preview.pages.dev", ]) { const response = await fetch(`${http}/sessions`, { method: "POST", @@ -122,6 +124,12 @@ describe("Node collaboration relay", () => { 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); }); it("rejects an oversized session-create body by declared length and by count", async () => { diff --git a/workers/collab/src/index.ts b/workers/collab/src/index.ts index a45730f9cd..f76b1fcc9a 100644 --- a/workers/collab/src/index.ts +++ b/workers/collab/src/index.ts @@ -64,6 +64,13 @@ function isAllowedOrigin(originHeader: string | null, envAllowed?: string): bool const originUrl = new URL(originHeader); const host = originUrl.hostname; if (host === "localhost" || host === "127.0.0.1" || host.endsWith(".localhost")) return true; + if ( + !envAllowed && + originUrl.protocol === "https:" && + (host === "opengeos.org" || host.endsWith(".geolibre-preview.pages.dev")) + ) { + return true; + } return allowedList.some((allowed) => { if (allowed === "*") return true; try { From a2989bd07aaa3474d28056b7e8c8b058ca3c8ea1 Mon Sep 17 00:00:00 2001 From: giswqs Date: Wed, 19 Aug 2026 22:46:21 -0400 Subject: [PATCH 3/4] Address CodeRabbit review feedback - Document every default collaboration origin category so the operator note matches the relay policy. --- docs/collaboration.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/collaboration.md b/docs/collaboration.md index ca84fb3447..05eb28c5b4 100644 --- a/docs/collaboration.md +++ b/docs/collaboration.md @@ -257,10 +257,11 @@ 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 -> hosted app origins (`geolibre.app`, `web.geolibre.app`, its legacy -> `viewer.geolibre.app` alias, and `studio.geolibre.app`) plus `localhost` for -> development and the project-owned Cloudflare/GitHub Pages preview origins) as -> browser-origin filtering +> hosted origins (`geolibre.app`, `web.geolibre.app`, its legacy +> `viewer.geolibre.app` alias, `studio.geolibre.app`, and +> `collab.geolibre.app`), the project-owned Cloudflare/GitHub Pages preview +> origins, loopback hosts (`localhost` and `127.0.0.1`), and +> `tauri://localhost`) 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 From f05b1eccce7a940d8d7c320c3cc0c221581e509d Mon Sep 17 00:00:00 2001 From: giswqs Date: Wed, 19 Aug 2026 23:12:14 -0400 Subject: [PATCH 4/4] Address review feedback - Remove blanket trust for the shared opengeos.org preview origin. - Restrict Cloudflare previews to one label, HTTPS, and the default port. - Make custom ALLOWED_ORIGINS settings authoritative over local defaults. - Cover nested, custom-port, shared-host, and configured-origin cases. - Document the exact default preview-origin policy and exclusions. --- docs/collaboration.md | 8 +++--- workers/collab-node/src/server.ts | 19 ++++++++----- workers/collab-node/test/relay.test.ts | 37 +++++++++++++++++++++++++- workers/collab/src/index.ts | 19 ++++++++----- 4 files changed, 65 insertions(+), 18 deletions(-) diff --git a/docs/collaboration.md b/docs/collaboration.md index 05eb28c5b4..dc8cf60b56 100644 --- a/docs/collaboration.md +++ b/docs/collaboration.md @@ -259,9 +259,11 @@ ephemeral and never written to a project file. > `Referer`) against `ALLOWED_ORIGINS` via `isAllowedOrigin` (defaults to the > hosted origins (`geolibre.app`, `web.geolibre.app`, its legacy > `viewer.geolibre.app` alias, `studio.geolibre.app`, and -> `collab.geolibre.app`), the project-owned Cloudflare/GitHub Pages preview -> origins, loopback hosts (`localhost` and `127.0.0.1`), and -> `tauri://localhost`) as browser-origin filtering +> `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 74e95b05f5..9a19b52e1e 100644 --- a/workers/collab-node/src/server.ts +++ b/workers/collab-node/src/server.ts @@ -77,13 +77,18 @@ 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 && - originUrl.protocol === "https:" && - (host === "opengeos.org" || host.endsWith(".geolibre-preview.pages.dev")) - ) { - 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; diff --git a/workers/collab-node/test/relay.test.ts b/workers/collab-node/test/relay.test.ts index e6b3447d31..621405fdc3 100644 --- a/workers/collab-node/test/relay.test.ts +++ b/workers/collab-node/test/relay.test.ts @@ -109,7 +109,6 @@ describe("Node collaboration relay", () => { "https://web.geolibre.app", "https://viewer.geolibre.app", "https://studio.geolibre.app", - "https://opengeos.org", "https://50e58010.geolibre-preview.pages.dev", ]) { const response = await fetch(`${http}/sessions`, { @@ -130,6 +129,42 @@ describe("Node collaboration relay", () => { 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 () => { diff --git a/workers/collab/src/index.ts b/workers/collab/src/index.ts index f76b1fcc9a..ec55b23c94 100644 --- a/workers/collab/src/index.ts +++ b/workers/collab/src/index.ts @@ -63,13 +63,18 @@ 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 && - originUrl.protocol === "https:" && - (host === "opengeos.org" || host.endsWith(".geolibre-preview.pages.dev")) - ) { - 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;