Skip to content

Commit 4f39e2f

Browse files
fix: add defense-in-depth key stripping in listConnections controller
Controller now destructures only { connectionId, type, email } from each connection before returning, so credential.key can never leak even if the service layer has a future regression. Test updated to verify stripping.
1 parent 4718c7e commit 4f39e2f

File tree

2 files changed

+19
-30
lines changed

2 files changed

+19
-30
lines changed

apps/api/v2/src/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller.spec.ts

Lines changed: 15 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -101,48 +101,34 @@ describe("CalUnifiedCalendarsController", () => {
101101
expect(result.data.connections).toEqual([]);
102102
});
103103

104-
it("should not include credential key even if service accidentally includes it", async () => {
105-
// Simulate a regression where the service layer accidentally includes credential key.
106-
// The controller passes through service data directly, so if the service leaks key,
107-
// it will appear in the response. This test documents that the protection lives in
108-
// UnifiedCalendarsFreebusyService.getConnections() which only returns
109-
// { connectionId, type, email } — never credential.key.
104+
it("should strip credential key even if service accidentally includes it (defense-in-depth)", async () => {
105+
// The controller destructures only { connectionId, type, email } from each connection,
106+
// so even if the service layer has a regression that leaks credential.key, the
107+
// controller response will never contain it.
110108
const connectionsWithKey = [
111109
{
112110
connectionId: "1",
113111
type: "google" as const,
114112
email: "user@gmail.com",
115-
key: { access_token: "SECRET" },
113+
key: { access_token: "SECRET_TOKEN", refresh_token: "SECRET_REFRESH" },
116114
},
117115
];
118116
mockFreebusyService.getConnections.mockResolvedValue(connectionsWithKey);
119117

120-
const result = await controller.listConnections(userId);
121-
const serialized = JSON.stringify(result);
122-
123-
// Verify core fields are present
124-
expect(result.data.connections[0]).toHaveProperty("connectionId", "1");
125-
expect(result.data.connections[0]).toHaveProperty("type", "google");
126-
expect(result.data.connections[0]).toHaveProperty("email", "user@gmail.com");
127-
// Document: controller currently passes through extra fields — key leak prevention
128-
// is the responsibility of the service layer (getConnections returns only safe fields).
129-
// This test verifies the expected shape; the service-level tests in
130-
// unified-calendars-freebusy.service.spec.ts verify no extra fields are returned.
131-
expect(serialized).toContain('"connectionId"');
132-
});
133-
134-
it("service getConnections should only return connectionId, type, email — no credential key", async () => {
135-
// Verify the service contract: getConnections returns ONLY safe fields
136-
const safeConnections = [{ connectionId: "1", type: "google" as const, email: "user@gmail.com" }];
137-
mockFreebusyService.getConnections.mockResolvedValue(safeConnections);
138-
139118
const result = await controller.listConnections(userId);
140119
const conn = result.data.connections[0];
120+
const serialized = JSON.stringify(result);
141121

142-
expect(Object.keys(conn)).toEqual(expect.arrayContaining(["connectionId", "type", "email"]));
122+
// Core fields are present
123+
expect(conn).toHaveProperty("connectionId", "1");
124+
expect(conn).toHaveProperty("type", "google");
125+
expect(conn).toHaveProperty("email", "user@gmail.com");
126+
// key is stripped by the controller's destructuring — this is the key assertion
143127
expect(conn).not.toHaveProperty("key");
144-
expect(conn).not.toHaveProperty("access_token");
145-
expect(JSON.stringify(result)).not.toContain("SECRET");
128+
expect(serialized).not.toContain("SECRET_TOKEN");
129+
expect(serialized).not.toContain("SECRET_REFRESH");
130+
// Only the 3 safe fields exist
131+
expect(Object.keys(conn)).toEqual(["connectionId", "type", "email"]);
146132
});
147133
});
148134

apps/api/v2/src/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,12 @@ export class CalUnifiedCalendarsController {
5757
})
5858
async listConnections(@GetUser("id") userId: number): Promise<ListConnectionsOutput> {
5959
const connections = await this.freebusyService.getConnections(userId);
60+
// Defense-in-depth: only forward the public fields so that any future service
61+
// regression that accidentally includes credential.key cannot leak to the client.
62+
const safeConnections = connections.map(({ connectionId, type, email }) => ({ connectionId, type, email }));
6063
return {
6164
status: SUCCESS_STATUS,
62-
data: { connections },
65+
data: { connections: safeConnections },
6366
};
6467
}
6568

0 commit comments

Comments
 (0)