From 125c36c00aa535f3dcdceebc89ff6b1797cfe54c Mon Sep 17 00:00:00 2001 From: Elior Hamamy Date: Sun, 12 Jul 2026 11:00:14 +0300 Subject: [PATCH 1/2] Propagate X-Data-Env from request onto outbound calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createClientFromRequest extracted the auth tokens and Base44-State but no data-env signal, so a backend function's user-scoped entity calls (base44.entities.X...) always hit production data even when the app runs in test-data mode — the user JWT carries no environment, unlike the service token. Read X-Data-Env from the incoming request and add it to additionalHeaders so every outbound call (user + service role) carries it, keeping function data operations in the same environment as the triggering request. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/client.ts | 9 +++++++ tests/unit/client.test.js | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/client.ts b/src/client.ts index 8aa63f3f..4e6e9620 100644 --- a/src/client.ts +++ b/src/client.ts @@ -397,6 +397,7 @@ export function createClientFromRequest(request: Request): Base44Client { const serverUrlHeader = request.headers.get("Base44-Api-Url"); const functionsVersion = request.headers.get("Base44-Functions-Version"); const stateHeader = request.headers.get("Base44-State"); + const dataEnvHeader = request.headers.get("X-Data-Env"); if (!appId) { throw new Error( @@ -439,6 +440,14 @@ export function createClientFromRequest(request: Request): Base44Client { if (stateHeader) { additionalHeaders["Base44-State"] = stateHeader; } + // Propagate the data environment so entity operations from the function stay + // in the same environment (e.g. test data) as the triggering request. This + // matters for the user-scoped client: unlike the service token, the user JWT + // carries no data-env, so without forwarding this header the callbacks fall + // back to production data even when the app runs in test-data mode. + if (dataEnvHeader) { + additionalHeaders["X-Data-Env"] = dataEnvHeader; + } return createClient({ serverUrl: serverUrlHeader || "https://base44.app", diff --git a/tests/unit/client.test.js b/tests/unit/client.test.js index 4a7b3efa..042430a2 100644 --- a/tests/unit/client.test.js +++ b/tests/unit/client.test.js @@ -540,6 +540,61 @@ describe('Service Role Authorization Headers', () => { expect(scope.isDone()).toBe(true); }); + test('should propagate X-Data-Env header on user-scoped API requests when created from request', async () => { + const mockRequest = { + headers: { + get: (name) => { + const headers = { + 'Authorization': 'Bearer user-token-123', + 'Base44-App-Id': appId, + 'Base44-Api-Url': serverUrl, + 'X-Data-Env': 'dev' + }; + return headers[name] || null; + } + } + }; + + const client = createClientFromRequest(mockRequest); + + // The user-scoped client (not asServiceRole) must still carry the data env + // so test-mode function callbacks hit test data, not production. + scope.get(`/api/apps/${appId}/entities/Todo`) + .matchHeader('X-Data-Env', 'dev') + .matchHeader('Authorization', 'Bearer user-token-123') + .reply(200, { items: [], total: 0 }); + + await client.entities.Todo.list(); + + expect(scope.isDone()).toBe(true); + }); + + test('should not include X-Data-Env header when not present in original request', async () => { + const mockRequest = { + headers: { + get: (name) => { + const headers = { + 'Authorization': 'Bearer user-token-123', + 'Base44-App-Id': appId, + 'Base44-Api-Url': serverUrl + }; + return headers[name] || null; + } + } + }; + + const client = createClientFromRequest(mockRequest); + + scope.get(`/api/apps/${appId}/entities/Todo`) + .matchHeader('X-Data-Env', (val) => !val) // Should not have this header + .matchHeader('Authorization', 'Bearer user-token-123') + .reply(200, { items: [], total: 0 }); + + await client.entities.Todo.list(); + + expect(scope.isDone()).toBe(true); + }); + test('should not include Base44-State header when not present in original request', async () => { const mockRequest = { headers: { From 6d18cd03901bdccb2fe12d0156fdd6e62dc30213 Mon Sep 17 00:00:00 2001 From: Elior Hamamy Date: Sun, 12 Jul 2026 15:52:35 +0300 Subject: [PATCH 2/2] Forward only the known dev/prod X-Data-Env values Defense-in-depth: gate the propagated header on the closed dev/prod set instead of any truthy string, matching the backend contract and avoiding relaying an arbitrary caller-supplied header value onward. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/client.ts | 4 +++- tests/unit/client.test.js | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index 4e6e9620..630b8cfb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -445,7 +445,9 @@ export function createClientFromRequest(request: Request): Base44Client { // matters for the user-scoped client: unlike the service token, the user JWT // carries no data-env, so without forwarding this header the callbacks fall // back to production data even when the app runs in test-data mode. - if (dataEnvHeader) { + // Forward only the known closed set (matches the backend contract) rather + // than relaying an arbitrary attacker-supplied header value onward. + if (dataEnvHeader === "dev" || dataEnvHeader === "prod") { additionalHeaders["X-Data-Env"] = dataEnvHeader; } diff --git a/tests/unit/client.test.js b/tests/unit/client.test.js index 042430a2..5ba2eba2 100644 --- a/tests/unit/client.test.js +++ b/tests/unit/client.test.js @@ -569,6 +569,33 @@ describe('Service Role Authorization Headers', () => { expect(scope.isDone()).toBe(true); }); + test('should not forward an X-Data-Env value outside the dev/prod set', async () => { + const mockRequest = { + headers: { + get: (name) => { + const headers = { + 'Authorization': 'Bearer user-token-123', + 'Base44-App-Id': appId, + 'Base44-Api-Url': serverUrl, + 'X-Data-Env': 'evil' + }; + return headers[name] || null; + } + } + }; + + const client = createClientFromRequest(mockRequest); + + scope.get(`/api/apps/${appId}/entities/Todo`) + .matchHeader('X-Data-Env', (val) => !val) // arbitrary value must not be relayed + .matchHeader('Authorization', 'Bearer user-token-123') + .reply(200, { items: [], total: 0 }); + + await client.entities.Todo.list(); + + expect(scope.isDone()).toBe(true); + }); + test('should not include X-Data-Env header when not present in original request', async () => { const mockRequest = { headers: {