Skip to content

Commit d6dd7ef

Browse files
eliorhElior Hamamyclaude
authored
Propagate X-Data-Env from request onto outbound calls (#228)
* Propagate X-Data-Env from request onto outbound calls 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Elior Hamamy <eliorh@base44.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 87bba89 commit d6dd7ef

2 files changed

Lines changed: 93 additions & 0 deletions

File tree

src/client.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,7 @@ export function createClientFromRequest(request: Request): Base44Client {
397397
const serverUrlHeader = request.headers.get("Base44-Api-Url");
398398
const functionsVersion = request.headers.get("Base44-Functions-Version");
399399
const stateHeader = request.headers.get("Base44-State");
400+
const dataEnvHeader = request.headers.get("X-Data-Env");
400401

401402
if (!appId) {
402403
throw new Error(
@@ -439,6 +440,16 @@ export function createClientFromRequest(request: Request): Base44Client {
439440
if (stateHeader) {
440441
additionalHeaders["Base44-State"] = stateHeader;
441442
}
443+
// Propagate the data environment so entity operations from the function stay
444+
// in the same environment (e.g. test data) as the triggering request. This
445+
// matters for the user-scoped client: unlike the service token, the user JWT
446+
// carries no data-env, so without forwarding this header the callbacks fall
447+
// back to production data even when the app runs in test-data mode.
448+
// Forward only the known closed set (matches the backend contract) rather
449+
// than relaying an arbitrary attacker-supplied header value onward.
450+
if (dataEnvHeader === "dev" || dataEnvHeader === "prod") {
451+
additionalHeaders["X-Data-Env"] = dataEnvHeader;
452+
}
442453

443454
return createClient({
444455
serverUrl: serverUrlHeader || "https://base44.app",

tests/unit/client.test.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,88 @@ describe('Service Role Authorization Headers', () => {
540540
expect(scope.isDone()).toBe(true);
541541
});
542542

543+
test('should propagate X-Data-Env header on user-scoped API requests when created from request', async () => {
544+
const mockRequest = {
545+
headers: {
546+
get: (name) => {
547+
const headers = {
548+
'Authorization': 'Bearer user-token-123',
549+
'Base44-App-Id': appId,
550+
'Base44-Api-Url': serverUrl,
551+
'X-Data-Env': 'dev'
552+
};
553+
return headers[name] || null;
554+
}
555+
}
556+
};
557+
558+
const client = createClientFromRequest(mockRequest);
559+
560+
// The user-scoped client (not asServiceRole) must still carry the data env
561+
// so test-mode function callbacks hit test data, not production.
562+
scope.get(`/api/apps/${appId}/entities/Todo`)
563+
.matchHeader('X-Data-Env', 'dev')
564+
.matchHeader('Authorization', 'Bearer user-token-123')
565+
.reply(200, { items: [], total: 0 });
566+
567+
await client.entities.Todo.list();
568+
569+
expect(scope.isDone()).toBe(true);
570+
});
571+
572+
test('should not forward an X-Data-Env value outside the dev/prod set', async () => {
573+
const mockRequest = {
574+
headers: {
575+
get: (name) => {
576+
const headers = {
577+
'Authorization': 'Bearer user-token-123',
578+
'Base44-App-Id': appId,
579+
'Base44-Api-Url': serverUrl,
580+
'X-Data-Env': 'evil'
581+
};
582+
return headers[name] || null;
583+
}
584+
}
585+
};
586+
587+
const client = createClientFromRequest(mockRequest);
588+
589+
scope.get(`/api/apps/${appId}/entities/Todo`)
590+
.matchHeader('X-Data-Env', (val) => !val) // arbitrary value must not be relayed
591+
.matchHeader('Authorization', 'Bearer user-token-123')
592+
.reply(200, { items: [], total: 0 });
593+
594+
await client.entities.Todo.list();
595+
596+
expect(scope.isDone()).toBe(true);
597+
});
598+
599+
test('should not include X-Data-Env header when not present in original request', async () => {
600+
const mockRequest = {
601+
headers: {
602+
get: (name) => {
603+
const headers = {
604+
'Authorization': 'Bearer user-token-123',
605+
'Base44-App-Id': appId,
606+
'Base44-Api-Url': serverUrl
607+
};
608+
return headers[name] || null;
609+
}
610+
}
611+
};
612+
613+
const client = createClientFromRequest(mockRequest);
614+
615+
scope.get(`/api/apps/${appId}/entities/Todo`)
616+
.matchHeader('X-Data-Env', (val) => !val) // Should not have this header
617+
.matchHeader('Authorization', 'Bearer user-token-123')
618+
.reply(200, { items: [], total: 0 });
619+
620+
await client.entities.Todo.list();
621+
622+
expect(scope.isDone()).toBe(true);
623+
});
624+
543625
test('should not include Base44-State header when not present in original request', async () => {
544626
const mockRequest = {
545627
headers: {

0 commit comments

Comments
 (0)