Skip to content

Commit 9d94cab

Browse files
authored
fix(appkit): trim x-forwarded-user in core OBO path (#427)
* fix(appkit): trim x-forwarded-user in core OBO path resolveUserId and asUser now trim the x-forwarded-user header at read time, so surrounding whitespace can't fork user identity or the per-user analytics cache key. Mirrors the existing files-plugin precedent; whitespace-only values resolve to the missing-header path (prod throws, dev falls back). Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * test(appkit): cover x-forwarded-user whitespace normalization Lock the Phase 1 trim: resolveUserId/asUser normalize a padded x-forwarded-user to the bare id; whitespace-only takes the missing-header path (prod throws, dev falls back); and two OBO analytics requests differing only by header whitespace share one cache key. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * fix(appkit): trim x-forwarded-access-token + correct resolveUserId error Address Copilot review feedback on #427: - asUser now trims x-forwarded-access-token too, so a whitespace-only token is treated as missing rather than forwarded to ServiceContext as a bogus credential. - resolveUserId throws AuthenticationError.missingUserId() instead of passing a full sentence to missingToken() (which produced a doubled "Missing Missing ..." message), matching asUser. Adds tests for both. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> --------- Signed-off-by: Atila Fassina <atila@fassina.eu>
1 parent a956367 commit 9d94cab

3 files changed

Lines changed: 247 additions & 6 deletions

File tree

packages/appkit/src/plugin/plugin.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -410,12 +410,10 @@ export abstract class Plugin<
410410
* @throws AuthenticationError in production when no user header is present.
411411
*/
412412
protected resolveUserId(req: express.Request): string {
413-
const userId = req.header("x-forwarded-user");
413+
const userId = req.header("x-forwarded-user")?.trim();
414414
if (userId) return userId;
415415
if (process.env.NODE_ENV === "development") return getCurrentUserId();
416-
throw AuthenticationError.missingToken(
417-
"Missing x-forwarded-user header. Cannot resolve user ID.",
418-
);
416+
throw AuthenticationError.missingUserId();
419417
}
420418

421419
/**
@@ -429,8 +427,8 @@ export abstract class Plugin<
429427
* In development mode (`NODE_ENV=development`), skips user impersonation instead of throwing.
430428
*/
431429
asUser(req: express.Request): this {
432-
const token = req.header("x-forwarded-access-token");
433-
const userId = req.header("x-forwarded-user");
430+
const token = req.header("x-forwarded-access-token")?.trim();
431+
const userId = req.header("x-forwarded-user")?.trim();
434432
const userEmail = req.header("x-forwarded-email");
435433
const isDev = process.env.NODE_ENV === "development";
436434

packages/appkit/src/plugin/tests/asUser-proxy.test.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { AppManager } from "../../app";
3535
import { CacheManager } from "../../cache";
3636
import { getUserContext } from "../../context/execution-context";
3737
import { ServiceContext } from "../../context/service-context";
38+
import { AuthenticationError } from "../../errors/authentication";
3839
import { StreamManager } from "../../stream";
3940
import type { ITelemetry, TelemetryProvider } from "../../telemetry";
4041
import { TelemetryManager } from "../../telemetry";
@@ -124,6 +125,17 @@ class CallablePlugin extends Plugin<BasePluginConfig> {
124125
}
125126
}
126127

128+
/**
129+
* Exposes the `protected resolveUserId(req)` so the whitespace-normalization
130+
* tests can assert on the resolved identity directly (the value that feeds
131+
* the analytics per-user cache key / executorKey).
132+
*/
133+
class ResolveProbePlugin extends ProbePlugin {
134+
resolve(req: express.Request): string {
135+
return this.resolveUserId(req);
136+
}
137+
}
138+
127139
/** Exports returns `undefined` — must be treated as `{}`. */
128140
class NullExportsPlugin extends Plugin<BasePluginConfig> {
129141
exports(): undefined {
@@ -152,6 +164,41 @@ function createReqWithoutToken(): express.Request {
152164
} as unknown as express.Request;
153165
}
154166

167+
/**
168+
* OBO request with a caller-supplied `x-forwarded-user` value. Used by the
169+
* whitespace-normalization tests to feed padded / whitespace-only identities
170+
* through the same code path as `createReqWithObo`.
171+
*/
172+
function createReqWithUser(forwardedUser: string): express.Request {
173+
return {
174+
header: (name: string) => {
175+
const map: Record<string, string> = {
176+
"x-forwarded-access-token": "user-token-abc",
177+
"x-forwarded-user": forwardedUser,
178+
"x-forwarded-email": "alice@example.com",
179+
};
180+
return map[name.toLowerCase()];
181+
},
182+
} as unknown as express.Request;
183+
}
184+
185+
/**
186+
* OBO request with a caller-supplied `x-forwarded-access-token` value (and a
187+
* valid user). Used by the token-trim tests.
188+
*/
189+
function createReqWithToken(forwardedToken: string): express.Request {
190+
return {
191+
header: (name: string) => {
192+
const map: Record<string, string> = {
193+
"x-forwarded-access-token": forwardedToken,
194+
"x-forwarded-user": "alice",
195+
"x-forwarded-email": "alice@example.com",
196+
};
197+
return map[name.toLowerCase()];
198+
},
199+
} as unknown as express.Request;
200+
}
201+
155202
describe("Plugin.asUser proxy", () => {
156203
let mockTelemetry: ITelemetry;
157204
let mockCache: CacheManager;
@@ -584,4 +631,143 @@ describe("Plugin.asUser proxy", () => {
584631
expect(exports.label).toBe("hello");
585632
});
586633
});
634+
635+
// ── Whitespace normalization of x-forwarded-user (PR0 / OBO hardening) ──
636+
//
637+
// The core OBO path trims `x-forwarded-user` at both read sites
638+
// (`resolveUserId` and `asUser`). These tests lock that behavior so a
639+
// whitespace-variant header can never (a) mint a distinct identity or
640+
// (b) fork the per-user analytics cache key (which derives from the
641+
// value `resolveUserId` returns — see analytics `executorKey`).
642+
describe("x-forwarded-user whitespace normalization", () => {
643+
test("resolveUserId trims a padded x-forwarded-user to the bare id", () => {
644+
const plugin = new ResolveProbePlugin(config);
645+
646+
expect(plugin.resolve(createReqWithUser(" alice "))).toBe("alice");
647+
});
648+
649+
test("resolveUserId returns the same id for padded and unpadded headers", () => {
650+
const plugin = new ResolveProbePlugin(config);
651+
652+
const padded = plugin.resolve(createReqWithUser(" alice "));
653+
const unpadded = plugin.resolve(createReqWithUser("alice"));
654+
655+
// Same resolved id => same analytics per-user cache key (the cache key
656+
// derives from this resolved id via `executorKey`), so whitespace can
657+
// neither fork the cache nor bypass per-user isolation.
658+
expect(padded).toBe(unpadded);
659+
expect(padded).toBe("alice");
660+
});
661+
662+
test("asUser with a padded header builds the same identity as the unpadded case", () => {
663+
const plugin = new ProbePlugin(config);
664+
665+
const padded = plugin.asUser(createReqWithUser(" alice ")).observeSync();
666+
const unpadded = plugin.asUser(createReqWithUser("alice")).observeSync();
667+
668+
// The identity flowing into the user context is the trimmed value.
669+
expect(padded).toBe("alice");
670+
expect(unpadded).toBe("alice");
671+
expect(padded).toBe(unpadded);
672+
});
673+
674+
test("asUser passes the trimmed id into ServiceContext.createUserContext", () => {
675+
const plugin = new ProbePlugin(config);
676+
677+
plugin.asUser(createReqWithUser(" alice "));
678+
679+
// The first positional arg is the token, the second is the user id —
680+
// it must be the trimmed value, never the padded " alice ".
681+
expect(serviceContextMock.createUserContextSpy).toHaveBeenCalledWith(
682+
"user-token-abc",
683+
"alice",
684+
undefined,
685+
"alice@example.com",
686+
);
687+
});
688+
689+
describe("whitespace-only header takes the missing-header path", () => {
690+
let originalNodeEnv: string | undefined;
691+
692+
beforeEach(() => {
693+
originalNodeEnv = process.env.NODE_ENV;
694+
});
695+
696+
afterEach(() => {
697+
process.env.NODE_ENV = originalNodeEnv;
698+
});
699+
700+
test("resolveUserId throws AuthenticationError in production", () => {
701+
process.env.NODE_ENV = "production";
702+
const plugin = new ResolveProbePlugin(config);
703+
704+
// A whitespace-only header trims to "" (falsy) and is treated as
705+
// missing — it must NOT become a " " identity.
706+
expect(() => plugin.resolve(createReqWithUser(" "))).toThrow(
707+
AuthenticationError,
708+
);
709+
// The message must be the purpose-built missingUserId() text, not the
710+
// doubled "Missing Missing … in request headers" the old missingToken()
711+
// call produced.
712+
expect(() => plugin.resolve(createReqWithUser(" "))).toThrow(
713+
/User ID not available in request headers/,
714+
);
715+
});
716+
717+
test("resolveUserId falls back to the context user id in development", () => {
718+
process.env.NODE_ENV = "development";
719+
const plugin = new ResolveProbePlugin(config);
720+
721+
// Dev fallback resolves to the current context user id (here: the
722+
// mocked service principal), never the raw " " header.
723+
const resolved = plugin.resolve(createReqWithUser(" "));
724+
expect(resolved).not.toBe(" ");
725+
expect(resolved).toBe(serviceContextMock.serviceContext.serviceUserId);
726+
});
727+
});
728+
});
729+
730+
// `asUser` also trims `x-forwarded-access-token` at read time, so a
731+
// whitespace-only token is treated as missing (not forwarded to the SDK as a
732+
// bogus credential), and a padded token is normalized before it reaches
733+
// ServiceContext.
734+
describe("x-forwarded-access-token whitespace handling", () => {
735+
let originalNodeEnv: string | undefined;
736+
737+
beforeEach(() => {
738+
originalNodeEnv = process.env.NODE_ENV;
739+
});
740+
741+
afterEach(() => {
742+
process.env.NODE_ENV = originalNodeEnv;
743+
});
744+
745+
test("asUser throws in production when the token is whitespace-only", () => {
746+
process.env.NODE_ENV = "production";
747+
const plugin = new ProbePlugin(config);
748+
749+
// " " trims to "" (falsy) → treated as a missing token rather than
750+
// being forwarded to ServiceContext.createUserContext as a bogus value.
751+
expect(() => plugin.asUser(createReqWithToken(" "))).toThrow(
752+
AuthenticationError,
753+
);
754+
expect(() => plugin.asUser(createReqWithToken(" "))).toThrow(
755+
/Missing user token in request headers/,
756+
);
757+
});
758+
759+
test("asUser passes the trimmed token into ServiceContext.createUserContext", () => {
760+
const plugin = new ProbePlugin(config);
761+
762+
plugin.asUser(createReqWithToken(" user-token-abc "));
763+
764+
// First positional arg is the token — it must be the trimmed value.
765+
expect(serviceContextMock.createUserContextSpy).toHaveBeenCalledWith(
766+
"user-token-abc",
767+
"alice",
768+
undefined,
769+
"alice@example.com",
770+
);
771+
});
772+
});
587773
});

packages/appkit/src/plugins/analytics/tests/analytics.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,63 @@ describe("Analytics Plugin", () => {
543543
);
544544
});
545545

546+
test("OBO requests differing only by whitespace in x-forwarded-user share one cache key", async () => {
547+
const plugin = new AnalyticsPlugin(config);
548+
const { router, getHandler } = createMockRouter();
549+
550+
(plugin as any).app.getAppQuery = vi.fn().mockResolvedValue({
551+
query: "SELECT * FROM my_data",
552+
isAsUser: true,
553+
});
554+
555+
const executeMock = vi.fn().mockResolvedValue({
556+
result: { data: [{ owner: "alice-data" }] },
557+
});
558+
(plugin as any).SQLClient.executeStatement = executeMock;
559+
560+
plugin.injectRoutes(router);
561+
const handler = getHandler("POST", "/query/:query_key");
562+
563+
// Same user, but the forwarded header is padded with surrounding
564+
// whitespace. The OBO cache key derives from the trimmed user id
565+
// (executorKey = resolveUserId(req)), so this must hit the SAME cache
566+
// entry as the unpadded request below — no per-whitespace cache fork.
567+
const paddedReq = createMockRequest({
568+
params: { query_key: "my_data" },
569+
body: { parameters: {} },
570+
headers: {
571+
"x-forwarded-access-token": "alice-token",
572+
"x-forwarded-user": " alice ",
573+
},
574+
});
575+
const paddedRes = createMockResponse();
576+
await handler(paddedReq, paddedRes);
577+
578+
// Same user, unpadded header — must reuse the cached result.
579+
const bareReq = createMockRequest({
580+
params: { query_key: "my_data" },
581+
body: { parameters: {} },
582+
headers: {
583+
"x-forwarded-access-token": "alice-token",
584+
"x-forwarded-user": "alice",
585+
},
586+
});
587+
const bareRes = createMockResponse();
588+
await handler(bareReq, bareRes);
589+
590+
// Only one execution: the whitespace variant resolved to the same
591+
// per-user cache key as the bare id, so the second request was a hit.
592+
expect(executeMock).toHaveBeenCalledTimes(1);
593+
594+
// Both responses serve the same (cached) data.
595+
expect(paddedRes.write).toHaveBeenCalledWith(
596+
expect.stringContaining('"owner":"alice-data"'),
597+
);
598+
expect(bareRes.write).toHaveBeenCalledWith(
599+
expect.stringContaining('"owner":"alice-data"'),
600+
);
601+
});
602+
546603
test("should handle AbortSignal cancellation", async () => {
547604
const plugin = new AnalyticsPlugin(config);
548605
const { router, getHandler } = createMockRouter();

0 commit comments

Comments
 (0)