Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions apps/api/src/template/http-schemas/template.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,15 @@ export const GetTemplatesListResponseSchema = z.object({
export type GetTemplatesListResponse = z.infer<typeof GetTemplatesListResponseSchema>;

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 });
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/template/routes/templates/templates.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ const getTemplateByIdRoute = createRoute({
}
}
},
400: {
description: "Invalid template ID"
},
404: {
description: "Template not found"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
4 changes: 4 additions & 0 deletions apps/api/test/functional/__snapshots__/docs.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
Expand Down Expand Up @@ -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",
},
Expand Down
43 changes: 43 additions & 0 deletions apps/api/test/functional/templates.spec.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
});
});
Loading