diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index f02a1ba1b..d4fa9cb97 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -288,6 +288,7 @@ export function stripSensitiveFields( // Connection essentials a connect-level recipient is allowed to see. const CONNECT_LEVEL_FIELDS = new Set([ "id", + "syncId", "userId", "ownerId", "ownerUsername", diff --git a/src/backend/sync/server/routes.ts b/src/backend/sync/server/routes.ts index a6cfa5c0e..5e5177efe 100644 --- a/src/backend/sync/server/routes.ts +++ b/src/backend/sync/server/routes.ts @@ -360,7 +360,7 @@ router.get("/v2/events", authenticateJWT, (req: Request, res: Response) => { * /sync/v2/hosts/{syncId}: * get: * summary: This server's id for a host, by its sync id - * description: For a linked desktop that asks the server to act on a host (a tunnel through it), which needs the server's own id. Only for hosts the caller can see. + * description: For a linked desktop that asks the server to act on a host (a tunnel through it), which needs the server's own id. Only for hosts the caller can connect to. * tags: * - Sync * parameters: @@ -373,7 +373,7 @@ router.get("/v2/events", authenticateJWT, (req: Request, res: Response) => { * 200: * description: The host's id and name. * 404: - * description: No such host the caller can see. + * description: No such host the caller can connect to. */ router.get( "/v2/hosts/:syncId", @@ -393,7 +393,7 @@ router.get( const access = await PermissionManager.getInstance().canAccessHost( userId, hostId, - "view", + "connect", ); if (!access.hasAccess) return res.status(404).json({ error: "Not found" }); diff --git a/src/backend/tests/database/routes/host-normalizers.test.ts b/src/backend/tests/database/routes/host-normalizers.test.ts index 6a0655b23..f94e31dd0 100644 --- a/src/backend/tests/database/routes/host-normalizers.test.ts +++ b/src/backend/tests/database/routes/host-normalizers.test.ts @@ -497,3 +497,11 @@ describe("transformHostResponse terminal fields", () => { expect(shared.terminalConfig).toEqual({ keepaliveInterval: 9 }); }); }); + +it("preserves a shared host sync identity for connect-only recipients", () => { + const result = sanitizeHostForRecipient( + { id: 9, syncId: "remote-host-41", password: "secret", notes: "private" }, + "connect", + ); + expect(result).toEqual({ id: 9, syncId: "remote-host-41" }); +}); diff --git a/src/backend/tests/sync/server/host-lookup.test.ts b/src/backend/tests/sync/server/host-lookup.test.ts new file mode 100644 index 000000000..f410f6d0f --- /dev/null +++ b/src/backend/tests/sync/server/host-lookup.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const { findHostIdBySyncId, canAccessHost } = vi.hoisted(() => ({ + findHostIdBySyncId: vi.fn(), + canAccessHost: vi.fn(), +})); +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) }, +})); +vi.mock("../../../utils/logger.js", () => ({ syncLogger: { error: vi.fn() } })); +vi.mock("../../../utils/app-version.js", () => ({ getLocalVersion: vi.fn() })); +vi.mock("../../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() })); +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentHostResolutionRepository: () => ({ findHostIdBySyncId }), + createCurrentHostRepository: () => ({ + findById: async () => ({ name: "Shared host" }), + }), +})); +vi.mock("../../../utils/permission-manager.js", () => ({ + PermissionManager: { getInstance: () => ({ canAccessHost }) }, +})); +vi.mock("../../../sync/entities.js", () => ({ + registerCoreSyncEntities: vi.fn(), +})); +vi.mock("../../../sync/records.js", () => ({})); +vi.mock("../../../sync/server/feed.js", () => ({})); +vi.mock("../../../sync/server/push.js", () => ({})); +vi.mock("../../../database/routes/branding-settings.js", () => ({})); + +import router from "../../../sync/server/routes.js"; + +const route = router.stack.find( + (layer) => layer.route?.path === "/v2/hosts/:syncId", +)!.route!; +const handler = route.stack[route.stack.length - 1].handle; + +beforeEach(() => { + vi.clearAllMocks(); + findHostIdBySyncId.mockResolvedValue(41); + canAccessHost.mockImplementation(async (_user, _host, action) => ({ + hasAccess: action === "connect", + })); +}); + +async function lookup() { + const res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; + await handler( + { userId: "recipient", params: { syncId: "host-sync-id" } } as never, + res as never, + vi.fn(), + ); + return res; +} + +it("resolves the server ID for a connect-only recipient", async () => { + const res = await lookup(); + expect(findHostIdBySyncId).toHaveBeenCalledWith("host-sync-id"); + expect(canAccessHost).toHaveBeenCalledWith("recipient", 41, "connect"); + expect(res.json).toHaveBeenCalledWith({ id: 41, name: "Shared host" }); +}); + +it("does not disclose a host the caller cannot connect to", async () => { + canAccessHost.mockResolvedValue({ hasAccess: false }); + const res = await lookup(); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: "Not found" }); +}); + +it("returns 404 for a missing sync ID", async () => { + findHostIdBySyncId.mockResolvedValue(null); + const res = await lookup(); + expect(res.status).toHaveBeenCalledWith(404); + expect(canAccessHost).not.toHaveBeenCalled(); +});