diff --git a/app/server/headscale/api/endpoints/nodes.ts b/app/server/headscale/api/endpoints/nodes.ts index 8fd8144..e26d6ac 100644 --- a/app/server/headscale/api/endpoints/nodes.ts +++ b/app/server/headscale/api/endpoints/nodes.ts @@ -145,7 +145,11 @@ export default defineApiEndpoints((client, apiKey) => ({ }, renameNode: async (nodeId, newName) => { - await client.apiFetch("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey); + await client.apiFetch( + "POST", + `v1/node/${nodeId}/rename/${encodeURIComponent(newName)}`, + apiKey, + ); }, setNodeTags: async (nodeId, tags) => { diff --git a/app/server/headscale/api/endpoints/users.ts b/app/server/headscale/api/endpoints/users.ts index a509e8d..c82f7e6 100644 --- a/app/server/headscale/api/endpoints/users.ts +++ b/app/server/headscale/api/endpoints/users.ts @@ -78,6 +78,10 @@ export default defineApiEndpoints((client, apiKey) => ({ }, renameUser: async (oldId, newName) => { - await client.apiFetch("POST", `v1/user/${oldId}/rename/${newName}`, apiKey); + await client.apiFetch( + "POST", + `v1/user/${oldId}/rename/${encodeURIComponent(newName)}`, + apiKey, + ); }, })); diff --git a/tests/unit/headscale/api-paths.test.ts b/tests/unit/headscale/api-paths.test.ts new file mode 100644 index 0000000..4fed422 --- /dev/null +++ b/tests/unit/headscale/api-paths.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "vitest"; + +import type { HeadscaleApiInterface } from "~/server/headscale/api"; +import nodeEndpoints from "~/server/headscale/api/endpoints/nodes"; +import userEndpoints from "~/server/headscale/api/endpoints/users"; + +type ApiFetchArgs = Parameters; + +function createApiClientRecorder() { + const calls: Array<{ + method: ApiFetchArgs[0]; + apiPath: ApiFetchArgs[1]; + apiKey: ApiFetchArgs[2]; + bodyOrQuery: ApiFetchArgs[3]; + }> = []; + + const client = { + isAtleast: () => false, + rawFetch: async () => { + throw new Error("rawFetch should not be called"); + }, + apiFetch: async ( + method: ApiFetchArgs[0], + apiPath: ApiFetchArgs[1], + apiKey: ApiFetchArgs[2], + bodyOrQuery?: ApiFetchArgs[3], + ) => { + calls.push({ method, apiPath, apiKey, bodyOrQuery }); + return undefined as T; + }, + } as HeadscaleApiInterface["clientHelpers"]; + + return { calls, client }; +} + +describe("Headscale API path encoding", () => { + test("encodes node rename names as a single URL segment", async () => { + const { calls, client } = createApiClientRecorder(); + + await nodeEndpoints(client, "api-key").renameNode("2", "../../1/expire"); + + expect(calls).toEqual([ + { + method: "POST", + apiPath: "v1/node/2/rename/..%2F..%2F1%2Fexpire", + apiKey: "api-key", + bodyOrQuery: undefined, + }, + ]); + }); + + test("encodes user rename names as a single URL segment", async () => { + const { calls, client } = createApiClientRecorder(); + + await userEndpoints(client, "api-key").renameUser("3", "../../../user/4/rename/pwned-user@"); + + expect(calls).toEqual([ + { + method: "POST", + apiPath: "v1/user/3/rename/..%2F..%2F..%2Fuser%2F4%2Frename%2Fpwned-user%40", + apiKey: "api-key", + bodyOrQuery: undefined, + }, + ]); + }); +});