@oneuptime/common 12.0.3 → 12.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/Models/DatabaseModels/CephCluster.ts +14 -0
  2. package/Models/DatabaseModels/CodeRepository.ts +14 -6
  3. package/Models/DatabaseModels/DockerHost.ts +14 -0
  4. package/Models/DatabaseModels/GlobalOidcProject.ts +18 -0
  5. package/Models/DatabaseModels/GlobalSsoProject.ts +18 -0
  6. package/Models/DatabaseModels/IoTFleet.ts +14 -0
  7. package/Models/DatabaseModels/KubernetesCluster.ts +14 -0
  8. package/Models/DatabaseModels/NetworkDevice.ts +14 -0
  9. package/Models/DatabaseModels/NetworkInterface.ts +18 -0
  10. package/Models/DatabaseModels/Project.ts +16 -2
  11. package/Models/DatabaseModels/ProxmoxCluster.ts +14 -0
  12. package/Models/DatabaseModels/Service.ts +30 -0
  13. package/Models/DatabaseModels/StatusPagePrivateUser.ts +30 -0
  14. package/Models/DatabaseModels/User.ts +30 -0
  15. package/Server/API/AIAgentDataAPI.ts +31 -0
  16. package/Server/API/DashboardAPI.ts +419 -12
  17. package/Server/API/GitHubAPI.ts +119 -283
  18. package/Server/API/UserAPI.ts +263 -1
  19. package/Server/EnvironmentConfig.ts +7 -3
  20. package/Server/Infrastructure/GlobalCache.ts +37 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.ts +21 -0
  22. package/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.ts +68 -0
  23. package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +100 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +214 -0
  25. package/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.ts +69 -0
  26. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
  27. package/Server/Services/AnalyticsDatabaseService.ts +72 -1
  28. package/Server/Services/CodeRepositoryService.ts +105 -2
  29. package/Server/Services/DatabaseService.ts +185 -2
  30. package/Server/Services/OpenTelemetryIngestService.ts +230 -26
  31. package/Server/Services/UserService.ts +184 -0
  32. package/Server/Utils/AI/Toolbox/CodeTools.ts +45 -4
  33. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +13 -0
  34. package/Server/Utils/CodeRepository/GitHub/GitHub.ts +157 -4
  35. package/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.ts +130 -0
  36. package/Server/Utils/PasswordHash.ts +306 -0
  37. package/Tests/App/Dashboard/OverviewCustomFields.test.tsx +333 -0
  38. package/Tests/App/Dashboard/UserCustomFields.test.tsx +392 -0
  39. package/Tests/Server/API/AIAgentDataRepositoryToken.test.ts +381 -0
  40. package/Tests/Server/API/DashboardPublicAttributeValuesAPI.test.ts +943 -0
  41. package/Tests/Server/API/DashboardPublicMetricsAggregateAPI.test.ts +1296 -0
  42. package/Tests/Server/API/DashboardPublicTemplatePayloads.test.ts +598 -0
  43. package/Tests/Server/API/GitHubAppInstallationBindingAPI.test.ts +539 -0
  44. package/Tests/Server/API/Helpers.ts +6 -1
  45. package/Tests/Server/API/UserProjectsAPI.test.ts +852 -0
  46. package/Tests/Server/Infrastructure/GlobalCache.test.ts +128 -0
  47. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +215 -0
  48. package/Tests/Server/Services/AddPerUserPasswordSaltMigration.test.ts +178 -0
  49. package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +582 -0
  50. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +5 -0
  51. package/Tests/Server/Services/CodeRepositoryInstallationBinding.test.ts +305 -0
  52. package/Tests/Server/Services/CodeRepositoryResolutionBinding.test.ts +147 -0
  53. package/Tests/Server/Services/DatabaseServicePerUserPasswordSalt.test.ts +870 -0
  54. package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +568 -0
  55. package/Tests/Server/Services/RestoreDroppedUniqueIndexesMigration.test.ts +386 -0
  56. package/Tests/Server/Services/RestoreServiceLowerNameIndexMigration.test.ts +240 -0
  57. package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +885 -0
  58. package/Tests/Server/Services/WidenHashedStringColumnsForScryptMigration.test.ts +220 -0
  59. package/Tests/Server/Utils/AI/CodeTools.test.ts +47 -1
  60. package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +10 -0
  61. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +120 -0
  62. package/Tests/Server/Utils/CodeRepository/GitHubInstallationBinding.test.ts +292 -0
  63. package/Tests/Server/Utils/GitHubInstallationOwnershipVerification.test.ts +340 -0
  64. package/Tests/Server/Utils/GitHubWebhookAndTreeCacheIsolation.test.ts +250 -0
  65. package/Tests/Server/Utils/PasswordHash.test.ts +555 -0
  66. package/Tests/Types/Database/ColumnLength.test.ts +7 -1
  67. package/Tests/Types/HashedStringPerUserSalt.test.ts +476 -0
  68. package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +293 -0
  69. package/Tests/Types/Monitor/HostAlertTemplates.test.ts +249 -0
  70. package/Tests/Types/Monitor/IotAlertTemplates.test.ts +320 -0
  71. package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +271 -0
  72. package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +696 -0
  73. package/Tests/UI/Utils/UserProjectsModelAPI.test.ts +714 -0
  74. package/Tests/Utils/TeamMembersByProject.test.ts +655 -0
  75. package/Types/Database/ColumnLength.ts +9 -1
  76. package/Types/Database/TableColumn.ts +11 -0
  77. package/Types/Database/UnsynchronizedIndex.ts +48 -0
  78. package/Types/HashedString.ts +139 -4
  79. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +85 -11
  80. package/UI/Components/Navbar/NavBar.tsx +12 -2
  81. package/UI/Components/Navbar/NavBarMenuModal.tsx +19 -7
  82. package/UI/Utils/ModelAPI/UserProjectsModelAPI.ts +257 -0
  83. package/Utils/TeamMembersByProject.ts +237 -0
  84. package/build/dist/Models/DatabaseModels/CephCluster.js +16 -1
  85. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  86. package/build/dist/Models/DatabaseModels/CodeRepository.js +14 -6
  87. package/build/dist/Models/DatabaseModels/CodeRepository.js.map +1 -1
  88. package/build/dist/Models/DatabaseModels/DockerHost.js +16 -1
  89. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  90. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +16 -1
  91. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -1
  92. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +16 -1
  93. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/IoTFleet.js +16 -1
  95. package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +16 -1
  97. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/NetworkDevice.js +16 -1
  99. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/NetworkInterface.js +16 -1
  101. package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
  102. package/build/dist/Models/DatabaseModels/Project.js +16 -2
  103. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  104. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +16 -1
  105. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  106. package/build/dist/Models/DatabaseModels/Service.js +28 -1
  107. package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
  108. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +32 -0
  109. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
  110. package/build/dist/Models/DatabaseModels/User.js +32 -0
  111. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  112. package/build/dist/Server/API/AIAgentDataAPI.js +21 -2
  113. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  114. package/build/dist/Server/API/DashboardAPI.js +304 -10
  115. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  116. package/build/dist/Server/API/GitHubAPI.js +94 -167
  117. package/build/dist/Server/API/GitHubAPI.js.map +1 -1
  118. package/build/dist/Server/API/UserAPI.js +187 -2
  119. package/build/dist/Server/API/UserAPI.js.map +1 -1
  120. package/build/dist/Server/EnvironmentConfig.js +7 -3
  121. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  122. package/build/dist/Server/Infrastructure/GlobalCache.js +29 -0
  123. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js +14 -0
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js.map +1 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js +59 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js.map +1 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +93 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js +101 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js +66 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js.map +1 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  136. package/build/dist/Server/Services/AnalyticsDatabaseService.js +57 -1
  137. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  138. package/build/dist/Server/Services/CodeRepositoryService.js +90 -2
  139. package/build/dist/Server/Services/CodeRepositoryService.js.map +1 -1
  140. package/build/dist/Server/Services/DatabaseService.js +160 -2
  141. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  142. package/build/dist/Server/Services/OpenTelemetryIngestService.js +155 -25
  143. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  144. package/build/dist/Server/Services/UserService.js +165 -0
  145. package/build/dist/Server/Services/UserService.js.map +1 -1
  146. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +31 -1
  147. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -1
  148. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +13 -0
  149. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  150. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +126 -4
  151. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
  152. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js +136 -0
  153. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js.map +1 -0
  154. package/build/dist/Server/Utils/PasswordHash.js +237 -0
  155. package/build/dist/Server/Utils/PasswordHash.js.map +1 -0
  156. package/build/dist/Types/Database/ColumnLength.js +9 -1
  157. package/build/dist/Types/Database/ColumnLength.js.map +1 -1
  158. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  159. package/build/dist/Types/Database/UnsynchronizedIndex.js +46 -0
  160. package/build/dist/Types/Database/UnsynchronizedIndex.js.map +1 -0
  161. package/build/dist/Types/HashedString.js +95 -5
  162. package/build/dist/Types/HashedString.js.map +1 -1
  163. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +51 -10
  164. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  165. package/build/dist/UI/Components/Navbar/NavBar.js +3 -1
  166. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  167. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +19 -7
  168. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  169. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js +156 -0
  170. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js.map +1 -0
  171. package/build/dist/Utils/TeamMembersByProject.js +145 -0
  172. package/build/dist/Utils/TeamMembersByProject.js.map +1 -0
  173. package/package.json +1 -1
@@ -0,0 +1,292 @@
1
+ import GitHubInstallationBinding from "../../../../Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding";
2
+ import ProjectService from "../../../../Server/Services/ProjectService";
3
+ import Project from "../../../../Models/DatabaseModels/Project";
4
+ import CodeRepository from "../../../../Models/DatabaseModels/CodeRepository";
5
+ import ObjectID from "../../../../Types/ObjectID";
6
+ import BadDataException from "../../../../Types/Exception/BadDataException";
7
+ import { getColumnAccessControl } from "../../../../Types/Database/AccessControl/ColumnAccessControl";
8
+ import { ColumnAccessControl } from "../../../../Types/BaseDatabase/AccessControl";
9
+ import Permission from "../../../../Types/Permission";
10
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
11
+
12
+ /*
13
+ * GHSA-xx95-gmcf-7q86. A GitHub App installation ID is a small integer, and
14
+ * the single GitHub App JWT this instance holds mints a write-scoped `ghs_`
15
+ * token for whichever installation it is handed. So "which project owns this
16
+ * installation?" has to be answered from a record only the verified install
17
+ * callback can write — never from the request, and never from a row the
18
+ * caller was able to create in a project they happen to own.
19
+ */
20
+
21
+ describe("GitHubInstallationBinding", () => {
22
+ let projectId: ObjectID;
23
+ let findOneBySpy: jest.SpyInstance;
24
+
25
+ beforeEach(() => {
26
+ projectId = ObjectID.generate();
27
+ findOneBySpy = jest.spyOn(ProjectService, "findOneBy");
28
+ });
29
+
30
+ afterEach(() => {
31
+ jest.restoreAllMocks();
32
+ });
33
+
34
+ function mockBoundProject(project: Project | null): void {
35
+ findOneBySpy.mockResolvedValue(project);
36
+ }
37
+
38
+ function boundProject(): Project {
39
+ const project: Project = new Project();
40
+ project.id = projectId;
41
+ return project;
42
+ }
43
+
44
+ describe("isInstallationBoundToProject", () => {
45
+ test("returns true when the project owns the installation", async () => {
46
+ mockBoundProject(boundProject());
47
+
48
+ await expect(
49
+ GitHubInstallationBinding.isInstallationBoundToProject({
50
+ projectId: projectId,
51
+ installationId: "12345678",
52
+ }),
53
+ ).resolves.toBe(true);
54
+ });
55
+
56
+ test("returns false when no project row matches project + installation", async () => {
57
+ mockBoundProject(null);
58
+
59
+ await expect(
60
+ GitHubInstallationBinding.isInstallationBoundToProject({
61
+ projectId: projectId,
62
+ installationId: "99999999",
63
+ }),
64
+ ).resolves.toBe(false);
65
+ });
66
+
67
+ /*
68
+ * The whole point: the query must be AND-ed on both the project and the
69
+ * installation. Matching on the project alone would return true for any
70
+ * installation ID the caller invented.
71
+ */
72
+ test("queries on BOTH the project id and the installation id", async () => {
73
+ mockBoundProject(boundProject());
74
+
75
+ await GitHubInstallationBinding.isInstallationBoundToProject({
76
+ projectId: projectId,
77
+ installationId: "12345678",
78
+ });
79
+
80
+ expect(findOneBySpy).toHaveBeenCalledTimes(1);
81
+
82
+ const findArgs: {
83
+ query: { _id?: string; gitHubAppInstallationId?: string };
84
+ props: { isRoot?: boolean };
85
+ } = findOneBySpy.mock.calls[0]![0] as {
86
+ query: { _id?: string; gitHubAppInstallationId?: string };
87
+ props: { isRoot?: boolean };
88
+ };
89
+
90
+ expect(findArgs.query._id).toBe(projectId.toString());
91
+ expect(findArgs.query.gitHubAppInstallationId).toBe("12345678");
92
+ expect(findArgs.props.isRoot).toBe(true);
93
+ });
94
+
95
+ /*
96
+ * An empty installation ID must never be treated as "bound" — otherwise a
97
+ * project with no GitHub connection at all would match every repository
98
+ * row whose installation ID had been cleared.
99
+ */
100
+ test("returns false for an empty installation id without querying", async () => {
101
+ await expect(
102
+ GitHubInstallationBinding.isInstallationBoundToProject({
103
+ projectId: projectId,
104
+ installationId: "",
105
+ }),
106
+ ).resolves.toBe(false);
107
+
108
+ expect(findOneBySpy).not.toHaveBeenCalled();
109
+ });
110
+
111
+ test("does not treat a different project's installation as bound", async () => {
112
+ // The service query is what enforces this; simulate the DB finding nothing.
113
+ mockBoundProject(null);
114
+
115
+ await expect(
116
+ GitHubInstallationBinding.isInstallationBoundToProject({
117
+ projectId: ObjectID.generate(),
118
+ installationId: "12345678",
119
+ }),
120
+ ).resolves.toBe(false);
121
+ });
122
+ });
123
+
124
+ /*
125
+ * The bulk-filter path. Callers listing a whole project's repositories read
126
+ * the binding once and compare in memory — every row in such a list is in
127
+ * the same project, so a per-row query would ask the same question N times.
128
+ */
129
+ describe("getBoundInstallationId", () => {
130
+ test("returns the installation the project owns", async () => {
131
+ const project: Project = boundProject();
132
+ project.gitHubAppInstallationId = "12345678";
133
+ mockBoundProject(project);
134
+
135
+ await expect(
136
+ GitHubInstallationBinding.getBoundInstallationId(projectId),
137
+ ).resolves.toBe("12345678");
138
+ });
139
+
140
+ /*
141
+ * Null, never "" or undefined: callers compare a row's installation ID
142
+ * against this, and an empty string would match a row whose ID had been
143
+ * cleared.
144
+ */
145
+ test("returns null for a project with no GitHub connection", async () => {
146
+ mockBoundProject(boundProject());
147
+
148
+ await expect(
149
+ GitHubInstallationBinding.getBoundInstallationId(projectId),
150
+ ).resolves.toBeNull();
151
+ });
152
+
153
+ test("returns null when the project does not exist", async () => {
154
+ mockBoundProject(null);
155
+
156
+ await expect(
157
+ GitHubInstallationBinding.getBoundInstallationId(projectId),
158
+ ).resolves.toBeNull();
159
+ });
160
+
161
+ test("reads as root, so a caller's permissions cannot blind the check", async () => {
162
+ mockBoundProject(boundProject());
163
+
164
+ await GitHubInstallationBinding.getBoundInstallationId(projectId);
165
+
166
+ const findArgs: {
167
+ query: { _id?: string };
168
+ props: { isRoot?: boolean };
169
+ } = findOneBySpy.mock.calls[0]![0] as {
170
+ query: { _id?: string };
171
+ props: { isRoot?: boolean };
172
+ };
173
+
174
+ expect(findArgs.query._id).toBe(projectId.toString());
175
+ expect(findArgs.props.isRoot).toBe(true);
176
+ });
177
+ });
178
+
179
+ describe("assertInstallationBoundToProject", () => {
180
+ test("resolves quietly when the binding exists", async () => {
181
+ mockBoundProject(boundProject());
182
+
183
+ await expect(
184
+ GitHubInstallationBinding.assertInstallationBoundToProject({
185
+ projectId: projectId,
186
+ installationId: "12345678",
187
+ }),
188
+ ).resolves.toBeUndefined();
189
+ });
190
+
191
+ test("throws BadDataException when the binding is missing", async () => {
192
+ mockBoundProject(null);
193
+
194
+ await expect(
195
+ GitHubInstallationBinding.assertInstallationBoundToProject({
196
+ projectId: projectId,
197
+ installationId: "99999999",
198
+ }),
199
+ ).rejects.toBeInstanceOf(BadDataException);
200
+ });
201
+
202
+ /*
203
+ * The error must not confirm that the installation exists elsewhere, or
204
+ * the failure message becomes the enumeration oracle the fix removed.
205
+ */
206
+ test("does not leak the installation id or another tenant's existence", async () => {
207
+ mockBoundProject(null);
208
+
209
+ let thrown: Error | null = null;
210
+
211
+ try {
212
+ await GitHubInstallationBinding.assertInstallationBoundToProject({
213
+ projectId: projectId,
214
+ installationId: "99999999",
215
+ });
216
+ } catch (err) {
217
+ thrown = err as Error;
218
+ }
219
+
220
+ expect(thrown).toBeInstanceOf(BadDataException);
221
+ expect(thrown!.message).not.toContain("99999999");
222
+ expect(thrown!.message.toLowerCase()).not.toContain("another");
223
+ });
224
+ });
225
+ });
226
+
227
+ /*
228
+ * The binding is only trustworthy if nothing outside the verified install
229
+ * callback can write it. These assertions pin the column ACLs that make that
230
+ * true — a future permission tidy-up that re-opens either column silently
231
+ * reopens the vulnerability, so it should fail here first.
232
+ */
233
+ describe("Installation-id columns are not client-writable", () => {
234
+ test("CodeRepository.gitHubAppInstallationId cannot be created or updated by any API caller", () => {
235
+ const accessControl: ColumnAccessControl = getColumnAccessControl(
236
+ new CodeRepository(),
237
+ "gitHubAppInstallationId",
238
+ );
239
+
240
+ expect(accessControl.create).toEqual([]);
241
+ expect(accessControl.update).toEqual([]);
242
+ });
243
+
244
+ test("Project.gitHubAppInstallationId cannot be created or updated by any API caller", () => {
245
+ const accessControl: ColumnAccessControl = getColumnAccessControl(
246
+ new Project(),
247
+ "gitHubAppInstallationId",
248
+ );
249
+
250
+ expect(accessControl.create).toEqual([]);
251
+ expect(accessControl.update).toEqual([]);
252
+ });
253
+
254
+ /*
255
+ * Specifically the permissions an attacker gets for free by creating their
256
+ * own project. Being able to write either column from one of these is the
257
+ * whole exploit.
258
+ */
259
+ test.each([
260
+ Permission.ProjectOwner,
261
+ Permission.ProjectAdmin,
262
+ Permission.ProjectMember,
263
+ Permission.CreateCodeRepository,
264
+ Permission.EditCodeRepository,
265
+ Permission.EditProject,
266
+ ])(
267
+ "%s cannot write either installation-id column",
268
+ (permission: Permission) => {
269
+ const repositoryAccessControl: ColumnAccessControl =
270
+ getColumnAccessControl(new CodeRepository(), "gitHubAppInstallationId");
271
+ const projectAccessControl: ColumnAccessControl = getColumnAccessControl(
272
+ new Project(),
273
+ "gitHubAppInstallationId",
274
+ );
275
+
276
+ expect(repositoryAccessControl.create).not.toContain(permission);
277
+ expect(repositoryAccessControl.update).not.toContain(permission);
278
+ expect(projectAccessControl.create).not.toContain(permission);
279
+ expect(projectAccessControl.update).not.toContain(permission);
280
+ },
281
+ );
282
+
283
+ // Reading it stays available — the dashboard shows connection status from it.
284
+ test("the column remains readable so the dashboard can show connection status", () => {
285
+ const accessControl: ColumnAccessControl = getColumnAccessControl(
286
+ new CodeRepository(),
287
+ "gitHubAppInstallationId",
288
+ );
289
+
290
+ expect(accessControl.read.length).toBeGreaterThan(0);
291
+ });
292
+ });
@@ -0,0 +1,340 @@
1
+ import GitHubUtil from "../../../Server/Utils/CodeRepository/GitHub/GitHub";
2
+ import API from "../../../Utils/API";
3
+ import HTTPResponse from "../../../Types/API/HTTPResponse";
4
+ import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
5
+ import BadDataException from "../../../Types/Exception/BadDataException";
6
+ import { JSONObject } from "../../../Types/JSON";
7
+ import URL from "../../../Types/API/URL";
8
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
9
+
10
+ /*
11
+ * GHSA-xx95-gmcf-7q86 — proving the installer owns the installation.
12
+ *
13
+ * `installation_id` arrives on the install redirect as a bare integer in a
14
+ * query string. Anyone can type any value there, so on its own it proves
15
+ * nothing about who owns the installation — and OneUptime writes that value
16
+ * into Project.gitHubAppInstallationId, which every token-minting path then
17
+ * treats as authoritative.
18
+ *
19
+ * The OAuth `code` is what closes the gap: GitHub mints it for one specific
20
+ * GitHub account and it is single-use, so trading it in reveals which
21
+ * installations that account can actually administer.
22
+ */
23
+
24
+ jest.mock("../../../Server/EnvironmentConfig", () => {
25
+ return {
26
+ ...(jest.requireActual("../../../Server/EnvironmentConfig") as Record<
27
+ string,
28
+ unknown
29
+ >),
30
+ GitHubAppClientId: "test-client-id",
31
+ GitHubAppClientSecret: "test-client-secret",
32
+ };
33
+ });
34
+
35
+ function okResponse(data: JSONObject): HTTPResponse<JSONObject> {
36
+ return new HTTPResponse<JSONObject>(200, data, {});
37
+ }
38
+
39
+ function errorResponse(statusCode: number): HTTPErrorResponse {
40
+ return new HTTPErrorResponse(statusCode, {}, {});
41
+ }
42
+
43
+ function installationsPage(ids: Array<number>): JSONObject {
44
+ return {
45
+ installations: ids.map((id: number) => {
46
+ return { id: id };
47
+ }),
48
+ };
49
+ }
50
+
51
+ describe("GitHub installation ownership verification", () => {
52
+ let postSpy: jest.SpyInstance;
53
+ let getSpy: jest.SpyInstance;
54
+
55
+ beforeEach(() => {
56
+ postSpy = jest.spyOn(API, "post");
57
+ getSpy = jest.spyOn(API, "get");
58
+ });
59
+
60
+ afterEach(() => {
61
+ jest.restoreAllMocks();
62
+ });
63
+
64
+ describe("exchangeOAuthCodeForUserAccessToken", () => {
65
+ test("returns the user access token GitHub issues for the code", async () => {
66
+ postSpy.mockResolvedValue(okResponse({ access_token: "gho_user_token" }));
67
+
68
+ await expect(
69
+ GitHubUtil.exchangeOAuthCodeForUserAccessToken("the-code"),
70
+ ).resolves.toBe("gho_user_token");
71
+ });
72
+
73
+ test("sends the app credentials and the code to GitHub's token endpoint", async () => {
74
+ postSpy.mockResolvedValue(okResponse({ access_token: "gho_user_token" }));
75
+
76
+ await GitHubUtil.exchangeOAuthCodeForUserAccessToken("the-code");
77
+
78
+ const callArgs: {
79
+ url: URL;
80
+ data: JSONObject;
81
+ } = postSpy.mock.calls[0]![0] as { url: URL; data: JSONObject };
82
+
83
+ expect(callArgs.url.toString()).toContain(
84
+ "github.com/login/oauth/access_token",
85
+ );
86
+ expect(callArgs.data["client_id"]).toBe("test-client-id");
87
+ expect(callArgs.data["client_secret"]).toBe("test-client-secret");
88
+ expect(callArgs.data["code"]).toBe("the-code");
89
+ });
90
+
91
+ /*
92
+ * GitHub answers a bad, expired or already-redeemed code with HTTP 200 and
93
+ * an `error` body rather than a 4xx. Treating "not an HTTP error" as
94
+ * success would accept a replayed or invented code and let the caller skip
95
+ * verification entirely.
96
+ */
97
+ test("rejects a 200 response that carries no access token", async () => {
98
+ postSpy.mockResolvedValue(
99
+ okResponse({
100
+ error: "bad_verification_code",
101
+ error_description: "The code passed is incorrect or expired.",
102
+ }),
103
+ );
104
+
105
+ await expect(
106
+ GitHubUtil.exchangeOAuthCodeForUserAccessToken("replayed-code"),
107
+ ).rejects.toBeInstanceOf(BadDataException);
108
+ });
109
+
110
+ test("rejects when GitHub returns an HTTP error", async () => {
111
+ postSpy.mockResolvedValue(errorResponse(401));
112
+
113
+ await expect(
114
+ GitHubUtil.exchangeOAuthCodeForUserAccessToken("the-code"),
115
+ ).rejects.toBeInstanceOf(BadDataException);
116
+ });
117
+ });
118
+
119
+ describe("listInstallationIdsForUserAccessToken", () => {
120
+ test("returns the installation ids the user can administer", async () => {
121
+ getSpy.mockResolvedValue(okResponse(installationsPage([111, 222])));
122
+
123
+ await expect(
124
+ GitHubUtil.listInstallationIdsForUserAccessToken("gho_user_token"),
125
+ ).resolves.toEqual(["111", "222"]);
126
+ });
127
+
128
+ test("asks GitHub as the user, not as the app", async () => {
129
+ getSpy.mockResolvedValue(okResponse(installationsPage([111])));
130
+
131
+ await GitHubUtil.listInstallationIdsForUserAccessToken("gho_user_token");
132
+
133
+ const callArgs: {
134
+ url: URL;
135
+ headers: { Authorization?: string };
136
+ } = getSpy.mock.calls[0]![0] as {
137
+ url: URL;
138
+ headers: { Authorization?: string };
139
+ };
140
+
141
+ expect(callArgs.url.toString()).toContain(
142
+ "api.github.com/user/installations",
143
+ );
144
+ expect(callArgs.headers.Authorization).toBe("Bearer gho_user_token");
145
+ });
146
+
147
+ /*
148
+ * A user with more than a page of installations must not silently lose the
149
+ * one they are installing — that would fail a legitimate install.
150
+ */
151
+ test("pages through every installation", async () => {
152
+ const firstPage: Array<number> = Array.from(
153
+ { length: 100 },
154
+ (_unused: unknown, index: number) => {
155
+ return index + 1;
156
+ },
157
+ );
158
+
159
+ getSpy
160
+ .mockResolvedValueOnce(okResponse(installationsPage(firstPage)))
161
+ .mockResolvedValueOnce(okResponse(installationsPage([999])));
162
+
163
+ const ids: Array<string> =
164
+ await GitHubUtil.listInstallationIdsForUserAccessToken("gho_token");
165
+
166
+ expect(getSpy).toHaveBeenCalledTimes(2);
167
+ expect(ids).toHaveLength(101);
168
+ expect(ids).toContain("999");
169
+ });
170
+
171
+ test("returns an empty list when the user administers nothing", async () => {
172
+ getSpy.mockResolvedValue(okResponse({ installations: [] }));
173
+
174
+ await expect(
175
+ GitHubUtil.listInstallationIdsForUserAccessToken("gho_token"),
176
+ ).resolves.toEqual([]);
177
+ });
178
+
179
+ test("rejects when GitHub returns an HTTP error", async () => {
180
+ getSpy.mockResolvedValue(errorResponse(401));
181
+
182
+ await expect(
183
+ GitHubUtil.listInstallationIdsForUserAccessToken("gho_token"),
184
+ ).rejects.toBeInstanceOf(BadDataException);
185
+ });
186
+ });
187
+
188
+ describe("assertUserControlsInstallation", () => {
189
+ beforeEach(() => {
190
+ postSpy.mockResolvedValue(okResponse({ access_token: "gho_user_token" }));
191
+ });
192
+
193
+ test("resolves when the installation is one the user administers", async () => {
194
+ getSpy.mockResolvedValue(okResponse(installationsPage([111, 222])));
195
+
196
+ await expect(
197
+ GitHubUtil.assertUserControlsInstallation({
198
+ oauthCode: "the-code",
199
+ installationId: "222",
200
+ }),
201
+ ).resolves.toBeUndefined();
202
+ });
203
+
204
+ /*
205
+ * The exact attack: a valid code for the attacker's own GitHub account,
206
+ * paired with a victim's installation ID typed into the redirect URL.
207
+ */
208
+ test("rejects an installation the authorizing account does not administer", async () => {
209
+ getSpy.mockResolvedValue(okResponse(installationsPage([111])));
210
+
211
+ await expect(
212
+ GitHubUtil.assertUserControlsInstallation({
213
+ oauthCode: "attackers-own-valid-code",
214
+ installationId: "99999999",
215
+ }),
216
+ ).rejects.toBeInstanceOf(BadDataException);
217
+ });
218
+
219
+ test("rejects when the user administers no installations at all", async () => {
220
+ getSpy.mockResolvedValue(okResponse({ installations: [] }));
221
+
222
+ await expect(
223
+ GitHubUtil.assertUserControlsInstallation({
224
+ oauthCode: "the-code",
225
+ installationId: "111",
226
+ }),
227
+ ).rejects.toBeInstanceOf(BadDataException);
228
+ });
229
+
230
+ /*
231
+ * Fail closed: a verification step that can be skipped by breaking the
232
+ * call it depends on is not a verification step.
233
+ */
234
+ test("rejects rather than passing when the code exchange fails", async () => {
235
+ postSpy.mockResolvedValue(okResponse({ error: "bad_verification_code" }));
236
+
237
+ await expect(
238
+ GitHubUtil.assertUserControlsInstallation({
239
+ oauthCode: "bad-code",
240
+ installationId: "111",
241
+ }),
242
+ ).rejects.toBeInstanceOf(BadDataException);
243
+
244
+ expect(getSpy).not.toHaveBeenCalled();
245
+ });
246
+
247
+ test("rejects rather than passing when the installation list cannot be read", async () => {
248
+ getSpy.mockResolvedValue(errorResponse(500));
249
+
250
+ await expect(
251
+ GitHubUtil.assertUserControlsInstallation({
252
+ oauthCode: "the-code",
253
+ installationId: "111",
254
+ }),
255
+ ).rejects.toBeInstanceOf(BadDataException);
256
+ });
257
+
258
+ // Ids cross the boundary as strings on one side and numbers on the other.
259
+ test("matches ids regardless of string/number representation", async () => {
260
+ getSpy.mockResolvedValue(okResponse(installationsPage([12345678])));
261
+
262
+ await expect(
263
+ GitHubUtil.assertUserControlsInstallation({
264
+ oauthCode: "the-code",
265
+ installationId: "12345678",
266
+ }),
267
+ ).resolves.toBeUndefined();
268
+ });
269
+
270
+ /*
271
+ * A prefix or substring match would let "1111" pass for installation
272
+ * "11110". Membership must be exact.
273
+ */
274
+ test("does not accept an installation id that merely looks similar", async () => {
275
+ getSpy.mockResolvedValue(okResponse(installationsPage([11110])));
276
+
277
+ await expect(
278
+ GitHubUtil.assertUserControlsInstallation({
279
+ oauthCode: "the-code",
280
+ installationId: "1111",
281
+ }),
282
+ ).rejects.toBeInstanceOf(BadDataException);
283
+ });
284
+ });
285
+ });
286
+
287
+ describe("GitHub OAuth verification when the app is not configured", () => {
288
+ afterEach(() => {
289
+ jest.restoreAllMocks();
290
+ jest.resetModules();
291
+ });
292
+
293
+ /*
294
+ * An instance without OAuth credentials must refuse to bind rather than
295
+ * quietly skipping verification — a soft fallback here would reopen the
296
+ * vulnerability for every self-hosted install that never set the vars.
297
+ */
298
+ test("refuses to exchange a code without client credentials", async () => {
299
+ jest.resetModules();
300
+ jest.doMock("../../../Server/EnvironmentConfig", () => {
301
+ return {
302
+ ...(jest.requireActual("../../../Server/EnvironmentConfig") as Record<
303
+ string,
304
+ unknown
305
+ >),
306
+ GitHubAppClientId: null,
307
+ GitHubAppClientSecret: null,
308
+ };
309
+ });
310
+
311
+ /*
312
+ * A re-require is the point here: the client credentials are read at
313
+ * module load, so the unconfigured branch can only be reached by reloading
314
+ * the module against a different mocked config.
315
+ */
316
+ const UnconfiguredGitHubUtil: typeof GitHubUtil =
317
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
318
+ require("../../../Server/Utils/CodeRepository/GitHub/GitHub").default;
319
+
320
+ const postSpy: jest.SpyInstance = jest.spyOn(API, "post");
321
+
322
+ let thrown: Error | null = null;
323
+
324
+ try {
325
+ await UnconfiguredGitHubUtil.exchangeOAuthCodeForUserAccessToken(
326
+ "the-code",
327
+ );
328
+ } catch (err) {
329
+ thrown = err as Error;
330
+ }
331
+
332
+ /*
333
+ * jest.resetModules() reloads the exception class too, so `instanceof`
334
+ * would compare two distinct copies of it — assert the contract instead.
335
+ */
336
+ expect(thrown).not.toBeNull();
337
+ expect(thrown!.message).toContain("GITHUB_APP_CLIENT_ID");
338
+ expect(postSpy).not.toHaveBeenCalled();
339
+ });
340
+ });