diff --git a/apps/api/src/template/http-schemas/template.schema.ts b/apps/api/src/template/http-schemas/template.schema.ts index d6fb81ea5f..df9eb26d90 100644 --- a/apps/api/src/template/http-schemas/template.schema.ts +++ b/apps/api/src/template/http-schemas/template.schema.ts @@ -37,10 +37,15 @@ export const GetTemplatesListResponseSchema = z.object({ export type GetTemplatesListResponse = z.infer; export const GetTemplateByIdParamsSchema = z.object({ - id: z.string().openapi({ - description: "Template ID", - example: "akash-network-cosmos-omnibus-agoric" - }) + // Template ids never contain a path separator (they're built by collapsing "/" and "\" to "-"), + // so rejecting them here blocks path traversal before the id reaches the filesystem. + id: z + .string() + .regex(/^[^/\\]+$/, "Invalid template ID") + .openapi({ + description: "Template ID", + example: "akash-network-cosmos-omnibus-agoric" + }) }); export const GetTemplateByIdResponseSchema = z.object({ data: TemplateSchema }); diff --git a/apps/api/src/template/routes/templates/templates.router.ts b/apps/api/src/template/routes/templates/templates.router.ts index afd86cac67..4b4cea11eb 100644 --- a/apps/api/src/template/routes/templates/templates.router.ts +++ b/apps/api/src/template/routes/templates/templates.router.ts @@ -55,6 +55,9 @@ const getTemplateByIdRoute = createRoute({ } } }, + 400: { + description: "Invalid template ID" + }, 404: { description: "Template not found" } diff --git a/apps/api/src/template/services/template-gallery/template-gallery.service.spec.ts b/apps/api/src/template/services/template-gallery/template-gallery.service.spec.ts index fb804e235e..45cdc3c2db 100644 --- a/apps/api/src/template/services/template-gallery/template-gallery.service.spec.ts +++ b/apps/api/src/template/services/template-gallery/template-gallery.service.spec.ts @@ -323,6 +323,18 @@ describe(TemplateGalleryService.name, () => { }) ); }); + + it("reads the correct file for a legit id containing dots", async () => { + const { service, fsMock } = setup(); + const template = { id: "akash-network-awesome-akash-DeepSeek-V3.1", name: "DeepSeek" }; + + fsMock.readFile.mockResolvedValue(JSON.stringify({ data: template })); + + const result = await service.getTemplateById("akash-network-awesome-akash-DeepSeek-V3.1"); + + expect(result).toEqual(template); + expect(fsMock.readFile).toHaveBeenCalledWith("/data/templates/v1/templates/akash-network-awesome-akash-DeepSeek-V3.1.json", "utf8"); + }); }); function setup(input?: { getTagsConfig?: () => TemplateTagsConfig }) { diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index 7c8ea9c89d..cc2bc245fe 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -14583,6 +14583,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "schema": { "description": "Template ID", "example": "akash-network-cosmos-omnibus-agoric", + "pattern": "^[^/\\\\]+$", "type": "string", }, }, @@ -14659,6 +14660,9 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` }, "description": "Return a template by id", }, + "400": { + "description": "Invalid template ID", + }, "404": { "description": "Template not found", }, diff --git a/apps/api/test/functional/templates.spec.ts b/apps/api/test/functional/templates.spec.ts new file mode 100644 index 0000000000..4874d6e278 --- /dev/null +++ b/apps/api/test/functional/templates.spec.ts @@ -0,0 +1,43 @@ +import { app, initDb } from "@src/rest-app"; + +describe("Templates", () => { + let isDbInitialized = false; + + const setup = async () => { + if (isDbInitialized) { + return; + } + + await initDb(); + isDbInitialized = true; + }; + + describe("GET /v1/templates/{id} path traversal", () => { + // An encoded separator (%2F -> "/", %5C -> "\") survives Hono routing as a single segment and is + // only decoded when the param is read, so the id schema sees the real separator and rejects it + // with a 400 before any file is read. A double-encoded payload decodes to a literal "%2F"/"%5C" + // (no separator), so it passes validation and resolves to a missing file -> 404. Either way no + // file contents leak (CON-428). The schema blocks both "/" and "\", so both are exercised here. + [ + { encodedId: "..%2F..%2F..%2Fetc%2Fpasswd", expectedStatus: 400 }, // ../../../etc/passwd + { encodedId: "%2Fetc%2Fpasswd", expectedStatus: 400 }, // /etc/passwd (absolute escape) + { encodedId: "..%2F..%2Fsecret", expectedStatus: 400 }, + { encodedId: "..%252F..%252Fsecret", expectedStatus: 404 }, // double-encoded: stays a literal "%2F" + { encodedId: "..%5C..%5Csecret", expectedStatus: 400 }, // ..\..\secret (backslash separator) + { encodedId: "..%255C..%255Csecret", expectedStatus: 404 } // double-encoded: stays a literal "%5C" + ].forEach(({ encodedId, expectedStatus }) => { + it(`rejects traversal attempt "${encodedId}" with ${expectedStatus} and no leaked file contents`, async () => { + await setup(); + + const response = await app.request(`/v1/templates/${encodedId}`, { + method: "GET", + headers: new Headers({ "Content-Type": "application/json" }) + }); + + expect(response.status).toBe(expectedStatus); + const body = await response.text(); + expect(body).not.toContain("root:x:"); // /etc/passwd marker — proves no file contents are returned + }); + }); + }); +});