Skip to content

Commit 7b66851

Browse files
ChenMachBaseclaude
andauthored
fix(connectors): follow-up hardening for callApi (#256) (#261)
- Percent-encode caller-supplied integration types and connector IDs in request URLs, so a runtime-built identifier can only select a connector, never re-target another route under the privileged token - Type ConnectorApiResponse.data as T | null to match the documented and implemented contract (null for binary and proxy-error responses) - Treat host: null like undefined when omitting the host field, since untyped callers write `host: x ?? null` - Add nock.disableNetConnect() to the proxy tests so interceptor mismatches fail fast instead of escaping as real network requests - Run test:types in CI so the type-contract tests are enforced Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 141f521 commit 7b66851

5 files changed

Lines changed: 52 additions & 13 deletions

File tree

.github/workflows/unit-tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,8 @@ jobs:
2626
- name: Install dependencies
2727
run: npm ci
2828

29+
- name: Run type tests
30+
run: npm run test:types
31+
2932
- name: Run unit tests
3033
run: npm run test:unit

src/modules/connectors.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export function createConnectorsModule(
4646
}
4747

4848
const response = await axios.get<ConnectorAccessTokenResponse>(
49-
`/apps/${appId}/external-auth/tokens/${integrationType}`
49+
`/apps/${appId}/external-auth/tokens/${encodeURIComponent(integrationType)}`
5050
);
5151

5252
// @ts-expect-error
@@ -61,7 +61,7 @@ export function createConnectorsModule(
6161
}
6262

6363
const response = await axios.get<ConnectorAccessTokenResponse>(
64-
`/apps/${appId}/external-auth/tokens/${integrationType}`
64+
`/apps/${appId}/external-auth/tokens/${encodeURIComponent(integrationType)}`
6565
);
6666

6767
const data = response as unknown as ConnectorAccessTokenResponse;
@@ -79,7 +79,7 @@ export function createConnectorsModule(
7979
}
8080

8181
const response = await axios.get<ConnectorAccessTokenResponse>(
82-
`/apps/${appId}/external-auth/tokens/connectors/${connectorId}`
82+
`/apps/${appId}/external-auth/tokens/connectors/${encodeURIComponent(connectorId)}`
8383
);
8484

8585
const data = response as unknown as ConnectorAccessTokenResponse;
@@ -100,7 +100,7 @@ export function createConnectorsModule(
100100
}
101101

102102
const response = await axios.get(
103-
`/apps/${appId}/app-user-auth/connectors/${connectorId}/token`
103+
`/apps/${appId}/app-user-auth/connectors/${encodeURIComponent(connectorId)}/token`
104104
);
105105

106106
const data = response as unknown as { access_token: string };
@@ -115,7 +115,7 @@ export function createConnectorsModule(
115115
}
116116

117117
const response = await axios.get(
118-
`/apps/${appId}/app-user-auth/connectors/${connectorId}/token`
118+
`/apps/${appId}/app-user-auth/connectors/${encodeURIComponent(connectorId)}/token`
119119
);
120120

121121
const data = response as unknown as ConnectorAccessTokenResponse;
@@ -130,9 +130,11 @@ export function createConnectorsModule(
130130
request: ConnectorApiRequest
131131
): Promise<ConnectorApiResponse<T>> {
132132
assertNonEmptyString(integrationType, "Integration type");
133+
// Encoded so a runtime-built identifier can only ever select a
134+
// connector, never re-target another route under this token.
133135
return proxyCall<T>(
134136
axios,
135-
`/apps/${appId}/connectors/${integrationType}/call`,
137+
`/apps/${appId}/connectors/${encodeURIComponent(integrationType)}/call`,
136138
request
137139
);
138140
},
@@ -172,9 +174,9 @@ async function proxyCall<T>(
172174

173175
const response = await axios.post(url, {
174176
method,
175-
// Omitted rather than sent as null so the proxy applies the connector's
176-
// declared default host.
177-
...(request.host === undefined ? {} : { host: request.host }),
177+
// Omitted when unset (undefined or null, since untyped callers write
178+
// either) so the proxy applies the connector's declared default host.
179+
...(request.host == null ? {} : { host: request.host }),
178180
path: request.path,
179181
query: request.query ?? {},
180182
headers: request.headers ?? {},
@@ -213,7 +215,7 @@ export function createUserConnectorsModule(
213215
}
214216

215217
const response = await axios.post(
216-
`/apps/${appId}/app-user-auth/connectors/${connectorId}/initiate`
218+
`/apps/${appId}/app-user-auth/connectors/${encodeURIComponent(connectorId)}/initiate`
217219
);
218220

219221
const data = response as unknown as { redirect_url: string };
@@ -226,7 +228,7 @@ export function createUserConnectorsModule(
226228
}
227229

228230
await axios.delete(
229-
`/apps/${appId}/app-user-auth/connectors/${connectorId}`
231+
`/apps/${appId}/app-user-auth/connectors/${encodeURIComponent(connectorId)}`
230232
);
231233
},
232234
};

src/modules/connectors.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export interface ConnectorApiResponse<T = unknown> {
102102
* The parsed upstream response body, or proxy error details when no response
103103
* was received. `null` when the response was binary — see {@link dataBase64}.
104104
*/
105-
data: T;
105+
data: T | null;
106106
/**
107107
* The response body base64-encoded, for the media types the connector declares
108108
* as binary (images, PDFs). Set instead of {@link data}, never alongside it.

tests/types/connectors.types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,17 @@ const rejectsLowercaseMethod = {
4747
path: "/2/tweets",
4848
} satisfies ConnectorApiRequest;
4949

50+
// Even with an explicit type argument, data stays nullable: binary and
51+
// proxy-error responses carry null, so it must be narrowed before use.
52+
declare const typedResponse: ConnectorApiResponse<{ id: string }>;
53+
const narrowableData: { id: string } | null = typedResponse.data;
54+
// @ts-expect-error data may be null until narrowed.
55+
const unnarrowedData: { id: string } = typedResponse.data;
56+
5057
void request;
5158
void response;
5259
void binaryResponse;
5360
void hostedRequest;
5461
void rejectsLowercaseMethod;
62+
void narrowableData;
63+
void unnarrowedData;

tests/unit/connectors-proxy.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ describe("Connectors module – metered connector proxy", () => {
1212
beforeEach(() => {
1313
base44 = createClient({ serverUrl, appId, serviceToken });
1414
scope = nock(serverUrl);
15+
nock.disableNetConnect();
1516
});
1617

1718
afterEach(() => {
1819
nock.cleanAll();
20+
nock.enableNetConnect();
1921
});
2022

2123
const proxyResponse = {
@@ -51,6 +53,23 @@ describe("Connectors module – metered connector proxy", () => {
5153
expect(received.headers).toEqual({});
5254
});
5355

56+
test("percent-encodes the integration type so it stays on the connectors route", async () => {
57+
// The route carries the service-role token, so a runtime-built identifier
58+
// containing slashes must select a (nonexistent) connector, not another route.
59+
scope
60+
.post(
61+
`/api/apps/${appId}/connectors/${encodeURIComponent("../evil/route")}/call`
62+
)
63+
.reply(200, proxyResponse);
64+
65+
const res = await base44.asServiceRole.connectors.callApi(
66+
"../evil/route" as any,
67+
{ path: "/x" }
68+
);
69+
70+
expect(res.success).toBe(true);
71+
});
72+
5473
test("forwards a named host, and omits it entirely when unset", async () => {
5574
// The payload is built field by field, so anything not explicitly forwarded
5675
// is silently dropped — which is what happened to `host` before this.
@@ -60,7 +79,7 @@ describe("Connectors module – metered connector proxy", () => {
6079
bodies.push(body);
6180
return true;
6281
})
63-
.twice()
82+
.times(3)
6483
.reply(200, proxyResponse);
6584

6685
await base44.asServiceRole.connectors.callApi("googlemaps", {
@@ -70,10 +89,16 @@ describe("Connectors module – metered connector proxy", () => {
7089
await base44.asServiceRole.connectors.callApi("googlemaps", {
7190
path: "/maps/api/geocode/json",
7291
});
92+
await base44.asServiceRole.connectors.callApi("googlemaps", {
93+
host: null as any,
94+
path: "/maps/api/geocode/json",
95+
});
7396

7497
expect(bodies[0].host).toBe("places");
7598
// Absent rather than null, so the proxy picks the connector's default host.
7699
expect("host" in bodies[1]).toBe(false);
100+
// Untyped callers write `host: x ?? null`; null must mean unset, not a host.
101+
expect("host" in bodies[2]).toBe(false);
77102
});
78103

79104
test("maps a binary response to dataBase64 + contentType", async () => {

0 commit comments

Comments
 (0)