Skip to content

Commit a9ac8a7

Browse files
authored
fix(api): reject a non-positive-integer installation id on the seven id routes (#9740)
Seven routes parsed an installation id and validated it with Number.isFinite alone, which accepts fractions and exponent notation -- Number("1.5") -> 1.5 and 0/negative all passed and were bound straight into the D1 lookup. A GitHub installation id is always a positive integer, and the repo's own sibling routes (the dead-letter-queue admin routes, the chat-qa :number param) already guard with !Number.isInteger(id) || id <= 0. Switch all seven -- GET/`/v1/installations/:id/{health,repair}`, POST `.../repair/refresh`, and their four `/v1/app/installations/:id/*` siblings (health, repair, repair/refresh, agent/bulk-settings) -- to that guard, returning the existing { error: "invalid_installation_id" } body with 400 unchanged. The two `/v1/internal/orb/*` routes that parse installationId from a JSON body are out of scope and untouched. Every one of the seven operations now declares 400 in the published OpenAPI document (the four app routes in orb-and-control-route-specs.ts, the three legacy routes' registerPath blocks in spec.ts); bulk-settings' existing 400 description is widened to cover the id cause too. openapi.json is regenerated. Adds a test asserting each of the seven routes rejects a fractional/zero/negative id with 400 invalid_installation_id, while a valid positive integer still reaches the handler. Closes #9716
1 parent 0f4276d commit a9ac8a7

5 files changed

Lines changed: 67 additions & 12 deletions

File tree

apps/loopover-ui/public/openapi.json

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17365,6 +17365,9 @@
1736517365
}
1736617366
}
1736717367
},
17368+
"400": {
17369+
"description": "Malformed installation id"
17370+
},
1736817371
"404": {
1736917372
"description": "Installation health not found"
1737017373
}
@@ -17407,6 +17410,9 @@
1740717410
}
1740817411
}
1740917412
},
17413+
"400": {
17414+
"description": "Malformed installation id"
17415+
},
1741017416
"404": {
1741117417
"description": "Installation health not found"
1741217418
}
@@ -17449,6 +17455,9 @@
1744917455
}
1745017456
}
1745117457
},
17458+
"400": {
17459+
"description": "Malformed installation id"
17460+
},
1745217461
"404": {
1745317462
"description": "Installation not found"
1745417463
}
@@ -24011,6 +24020,9 @@
2401124020
"200": {
2401224021
"description": "Installation health"
2401324022
},
24023+
"400": {
24024+
"description": "Malformed installation id"
24025+
},
2401424026
"401": {
2401524027
"description": "Not signed in"
2401624028
},
@@ -24052,6 +24064,9 @@
2405224064
"200": {
2405324065
"description": "Repair plan"
2405424066
},
24067+
"400": {
24068+
"description": "Malformed installation id"
24069+
},
2405524070
"401": {
2405624071
"description": "Not signed in"
2405724072
},
@@ -24093,6 +24108,9 @@
2409324108
"200": {
2409424109
"description": "Repair plan recomputed"
2409524110
},
24111+
"400": {
24112+
"description": "Malformed installation id"
24113+
},
2409624114
"401": {
2409724115
"description": "Not signed in"
2409824116
},
@@ -24136,7 +24154,7 @@
2413624154
"description": "Settings applied"
2413724155
},
2413824156
"400": {
24139-
"description": "Malformed settings"
24157+
"description": "Malformed installation id or settings"
2414024158
},
2414124159
"401": {
2414224160
"description": "Not signed in"

src/api/routes.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2698,23 +2698,23 @@ export function createApp() {
26982698

26992699
app.get("/v1/installations/:id/health", async (c) => {
27002700
const installationId = Number(c.req.param("id"));
2701-
if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400);
2701+
if (!Number.isInteger(installationId) || installationId <= 0) return c.json({ error: "invalid_installation_id" }, 400);
27022702
const health = await getInstallationHealth(c.env, installationId);
27032703
if (!health) return c.json({ error: "installation_health_not_found" }, 404);
27042704
return c.json(enrichInstallationHealth(health));
27052705
});
27062706

27072707
app.get("/v1/installations/:id/repair", async (c) => {
27082708
const installationId = Number(c.req.param("id"));
2709-
if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400);
2709+
if (!Number.isInteger(installationId) || installationId <= 0) return c.json({ error: "invalid_installation_id" }, 400);
27102710
const health = await getInstallationHealth(c.env, installationId);
27112711
if (!health) return c.json({ error: "installation_health_not_found" }, 404);
27122712
return c.json(await buildInstallationRepairDiagnostics(c.env, health));
27132713
});
27142714

27152715
app.post("/v1/installations/:id/repair/refresh", async (c) => {
27162716
const installationId = Number(c.req.param("id"));
2717-
if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400);
2717+
if (!Number.isInteger(installationId) || installationId <= 0) return c.json({ error: "invalid_installation_id" }, 400);
27182718
const refreshed = await refreshInstallationHealthForInstallation(c.env, installationId);
27192719
if (!refreshed) return c.json({ error: "installation_not_found" }, 404);
27202720
const health = await getInstallationHealth(c.env, installationId);
@@ -2744,7 +2744,7 @@ export function createApp() {
27442744
const resolved = await resolveAppInstallationScope(c);
27452745
if (resolved instanceof Response) return resolved;
27462746
const installationId = Number(c.req.param("id"));
2747-
if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400);
2747+
if (!Number.isInteger(installationId) || installationId <= 0) return c.json({ error: "invalid_installation_id" }, 400);
27482748
const health = await getInstallationHealth(c.env, installationId);
27492749
if (!health) return c.json({ error: "installation_health_not_found" }, 404);
27502750
if (!installationRecordInScope(resolved.scope, health)) return c.json({ error: "forbidden_installation" }, 403);
@@ -2755,7 +2755,7 @@ export function createApp() {
27552755
const resolved = await resolveAppInstallationScope(c);
27562756
if (resolved instanceof Response) return resolved;
27572757
const installationId = Number(c.req.param("id"));
2758-
if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400);
2758+
if (!Number.isInteger(installationId) || installationId <= 0) return c.json({ error: "invalid_installation_id" }, 400);
27592759
const health = await getInstallationHealth(c.env, installationId);
27602760
if (!health) return c.json({ error: "installation_health_not_found" }, 404);
27612761
if (!installationRecordInScope(resolved.scope, health)) return c.json({ error: "forbidden_installation" }, 403);
@@ -2766,7 +2766,7 @@ export function createApp() {
27662766
const resolved = await resolveAppInstallationScope(c);
27672767
if (resolved instanceof Response) return resolved;
27682768
const installationId = Number(c.req.param("id"));
2769-
if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400);
2769+
if (!Number.isInteger(installationId) || installationId <= 0) return c.json({ error: "invalid_installation_id" }, 400);
27702770
// Ownership is enforced BEFORE the refresh side effect so a tenant can never trigger repair on an
27712771
// installation they don't own; the existing health record supplies the account the scope is checked against.
27722772
const existing = await getInstallationHealth(c.env, installationId);
@@ -2788,7 +2788,7 @@ export function createApp() {
27882788
const resolved = await resolveAppInstallationScope(c);
27892789
if (resolved instanceof Response) return resolved;
27902790
const installationId = Number(c.req.param("id"));
2791-
if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400);
2791+
if (!Number.isInteger(installationId) || installationId <= 0) return c.json({ error: "invalid_installation_id" }, 400);
27922792
const installation = await getInstallation(c.env, installationId);
27932793
if (!installation) return c.json({ error: "installation_not_found" }, 404);
27942794
if (!installationRecordInScope(resolved.scope, { installationId: installation.id, accountLogin: installation.accountLogin })) {

src/openapi/orb-and-control-route-specs.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ const APP_ROUTES: SpecEntry[] = [
174174
tags: ["Control panel"],
175175
summary: "Return one installation's health summary",
176176
auth: "session",
177-
responses: { 200: { description: "Installation health" }, 404: { description: "No such installation" }, ...SESSION_AUTH_RESPONSES },
177+
responses: { 200: { description: "Installation health" }, 400: { description: "Malformed installation id" }, 404: { description: "No such installation" }, ...SESSION_AUTH_RESPONSES },
178178
},
179179
{
180180
method: "get",
@@ -183,7 +183,7 @@ const APP_ROUTES: SpecEntry[] = [
183183
tags: ["Control panel"],
184184
summary: "Return the repair plan for an unhealthy installation",
185185
auth: "session",
186-
responses: { 200: { description: "Repair plan" }, 404: { description: "No such installation" }, ...SESSION_AUTH_RESPONSES },
186+
responses: { 200: { description: "Repair plan" }, 400: { description: "Malformed installation id" }, 404: { description: "No such installation" }, ...SESSION_AUTH_RESPONSES },
187187
},
188188
{
189189
method: "post",
@@ -192,7 +192,7 @@ const APP_ROUTES: SpecEntry[] = [
192192
tags: ["Control panel"],
193193
summary: "Recompute an installation's repair plan",
194194
auth: "session",
195-
responses: { 200: { description: "Repair plan recomputed" }, 404: { description: "No such installation" }, ...SESSION_AUTH_RESPONSES },
195+
responses: { 200: { description: "Repair plan recomputed" }, 400: { description: "Malformed installation id" }, 404: { description: "No such installation" }, ...SESSION_AUTH_RESPONSES },
196196
},
197197
{
198198
method: "put",
@@ -201,7 +201,7 @@ const APP_ROUTES: SpecEntry[] = [
201201
tags: ["Control panel", "Agent automation"],
202202
summary: "Apply agent settings across every repo in an installation",
203203
auth: "session",
204-
responses: { 200: { description: "Settings applied" }, 400: { description: "Malformed settings" }, 404: { description: "No such installation" }, ...SESSION_AUTH_RESPONSES },
204+
responses: { 200: { description: "Settings applied" }, 400: { description: "Malformed installation id or settings" }, 404: { description: "No such installation" }, ...SESSION_AUTH_RESPONSES },
205205
},
206206
{
207207
method: "get",

src/openapi/spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,7 @@ export function buildOpenApiSpec() {
540540
request: { params: z.object({ id: z.string() }) },
541541
responses: {
542542
200: { description: "GitHub App installation health", content: { "application/json": { schema: InstallationHealthSchema } } },
543+
400: { description: "Malformed installation id" },
543544
404: { description: "Installation health not found" },
544545
},
545546
});
@@ -552,6 +553,7 @@ export function buildOpenApiSpec() {
552553
request: { params: z.object({ id: z.string() }) },
553554
responses: {
554555
200: { description: "GitHub App installation repair diagnostics", content: { "application/json": { schema: InstallationRepairSchema } } },
556+
400: { description: "Malformed installation id" },
555557
404: { description: "Installation health not found" },
556558
},
557559
});
@@ -564,6 +566,7 @@ export function buildOpenApiSpec() {
564566
request: { params: z.object({ id: z.string() }) },
565567
responses: {
566568
200: { description: "Refreshed GitHub App installation repair diagnostics", content: { "application/json": { schema: InstallationRepairSchema } } },
569+
400: { description: "Malformed installation id" },
567570
404: { description: "Installation not found" },
568571
},
569572
});

test/integration/app-installations-selfservice.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,40 @@ describe("tenant self-service installation health/repair (#7661)", () => {
170170
await expect(missing.json()).resolves.toMatchObject({ error: "installation_health_not_found" });
171171
});
172172

173+
it("#9716: rejects a fractional/exponent/zero/negative installation id with 400 on every id route (Number.isFinite let those through)", async () => {
174+
const app = createApp();
175+
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" });
176+
await seedFleet(env);
177+
178+
// A GitHub installation id is always a positive integer. Number.isFinite accepted "1.5"->1.5 (fractional) and
179+
// "0"/"-1" (non-positive), binding them straight into the D1 lookup; each must now be a 400. ("1e3"->1000 is a
180+
// genuine integer and is deliberately NOT rejected -- it is exponent notation for a valid id, asserted below.)
181+
const badIds = ["1.5", "0", "-1"];
182+
const routes: Array<{ path: (id: string) => string; method: "GET" | "POST" | "PUT"; body?: string }> = [
183+
{ path: (id) => `/v1/installations/${id}/health`, method: "GET" },
184+
{ path: (id) => `/v1/installations/${id}/repair`, method: "GET" },
185+
{ path: (id) => `/v1/installations/${id}/repair/refresh`, method: "POST" },
186+
{ path: (id) => `/v1/app/installations/${id}/health`, method: "GET" },
187+
{ path: (id) => `/v1/app/installations/${id}/repair`, method: "GET" },
188+
{ path: (id) => `/v1/app/installations/${id}/repair/refresh`, method: "POST" },
189+
{ path: (id) => `/v1/app/installations/${id}/agent/bulk-settings`, method: "PUT", body: JSON.stringify({ autonomy: {} }) },
190+
];
191+
for (const route of routes) {
192+
for (const badId of badIds) {
193+
const res = await app.request(
194+
route.path(badId),
195+
{ method: route.method, headers: apiHeaders(env), ...(route.body !== undefined ? { body: route.body } : {}) },
196+
env,
197+
);
198+
expect(res.status, `${route.method} ${route.path(badId)}`).toBe(400);
199+
await expect(res.json()).resolves.toMatchObject({ error: "invalid_installation_id" });
200+
}
201+
}
202+
203+
// A valid positive integer id still passes the guard and reaches the handler (200 for the owned installation).
204+
expect((await app.request("/v1/app/installations/600/health", { headers: apiHeaders(env) }, env)).status).toBe(200);
205+
});
206+
173207
it("scopes per-installation repair diagnostics and their error branches", async () => {
174208
const app = createApp();
175209
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" });

0 commit comments

Comments
 (0)