@checkstack/auth-backend 0.4.7 → 0.4.9

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # @checkstack/auth-backend
2
2
 
3
+ ## 0.4.9
4
+
5
+ ### Patch Changes
6
+
7
+ - 0ebbe56: Security Vulnerability Remediation completed:
8
+ - Refactored core authorization to Fail-Closed architecture with secure defaults.
9
+ - Implemented `assertTeamManagementAccess` to resolve BOLA in Teams Management.
10
+ - Protected internal S2S capabilities via explicit wildcard `serviceScope` definitions.
11
+ - Disarmed OS Command Injection in DiskCollector via strict regex validation and bash escaping.
12
+ - Re-architected inline script processing executing scripts in sandboxed Web Worker contexts.
13
+ - Isolated subprocess environment scopes in PingStrategy limiting variable leakage.
14
+ - Enforced strict token/API Key parsing with URLSearchParams checking.
15
+ - Explicitly fail-fast on missing DATABASE_URL configuration across independent backend clusters.
16
+ - Activated strict HTTP Security Headers (HSTS, CSP, X-Frame-Options) across the API automatically.
17
+ - Updated dependencies [0ebbe56]
18
+ - @checkstack/auth-common@0.5.6
19
+ - @checkstack/backend-api@0.8.1
20
+ - @checkstack/common@0.6.3
21
+ - @checkstack/command-backend@0.1.12
22
+ - @checkstack/notification-common@0.2.6
23
+
24
+ ## 0.4.8
25
+
26
+ ### Patch Changes
27
+
28
+ - Updated dependencies [869b4ab]
29
+ - @checkstack/backend-api@0.8.0
30
+ - @checkstack/command-backend@0.1.11
31
+
3
32
  ## 0.4.7
4
33
 
5
34
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/auth-backend",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -11,16 +11,16 @@
11
11
  "test": "bun test"
12
12
  },
13
13
  "dependencies": {
14
- "@checkstack/auth-common": "0.5.4",
15
- "@checkstack/backend-api": "0.5.2",
16
- "@checkstack/notification-common": "0.2.4",
17
- "@checkstack/command-backend": "0.1.8",
14
+ "@checkstack/auth-common": "0.5.5",
15
+ "@checkstack/backend-api": "0.8.0",
16
+ "@checkstack/notification-common": "0.2.5",
17
+ "@checkstack/command-backend": "0.1.11",
18
18
  "better-auth": "^1.4.7",
19
19
  "drizzle-orm": "^0.45.1",
20
20
  "hono": "^4.0.0",
21
21
  "jose": "^6.1.3",
22
22
  "zod": "^4.2.1",
23
- "@checkstack/common": "0.6.1"
23
+ "@checkstack/common": "0.6.2"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@checkstack/drizzle-helper": "0.0.3",
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from "bun:test";
2
+
3
+ describe("API Key Parsing", () => {
4
+ it("should extract application ID and secret safely", () => {
5
+ // This is essentially simulating the logic added in Fix 8.A
6
+ const token = "ck_123e4567-e89b-12d3-a456-426614174000_sec_test_12345";
7
+ const tokenWithoutPrefix = token.slice(3); // Remove "ck_"
8
+ const separatorIndex = tokenWithoutPrefix.indexOf("_", 36);
9
+
10
+ expect(separatorIndex).not.toBe(-1);
11
+
12
+ const applicationId = tokenWithoutPrefix.slice(0, separatorIndex);
13
+ const secret = tokenWithoutPrefix.slice(separatorIndex + 1);
14
+
15
+ expect(applicationId).toBe("123e4567-e89b-12d3-a456-426614174000");
16
+ expect(secret).toBe("sec_test_12345");
17
+ });
18
+
19
+ it("should reject improperly formatted tokens without throwing", () => {
20
+ const malformedToken = "ck_short_bad";
21
+ const tokenWithoutPrefix = malformedToken.slice(3); // Remove "ck_"
22
+ const separatorIndex = tokenWithoutPrefix.indexOf("_", 36);
23
+
24
+ expect(separatorIndex).toBe(-1);
25
+ });
26
+ });
package/src/index.ts CHANGED
@@ -349,10 +349,15 @@ export default createBackendPlugin({
349
349
  // The UUID is parts[1] and potentially includes more parts if UUID has dashes
350
350
  // For a UUID like "abc-def-ghi", after "ck_", we get the rest split by _
351
351
  // Safer approach: find the application ID by parsing
352
+ // Token format: ck_<uuid>_<secret>
353
+ // Parse using the known ck_ prefix and structured delimiter
352
354
  const tokenWithoutPrefix = token.slice(3); // Remove "ck_"
353
- // UUID is 36 chars, secret is 32 chars
354
- const applicationId = tokenWithoutPrefix.slice(0, 36);
355
- const secret = tokenWithoutPrefix.slice(37); // Skip the _ separator
355
+ // Find the last underscore: UUID may contain dashes but not underscores
356
+ // UUID is always 36 chars (8-4-4-4-12 with dashes)
357
+ const separatorIndex = tokenWithoutPrefix.indexOf("_", 36);
358
+ if (separatorIndex === -1) return; // Malformed token
359
+ const applicationId = tokenWithoutPrefix.slice(0, separatorIndex);
360
+ const secret = tokenWithoutPrefix.slice(separatorIndex + 1);
356
361
 
357
362
  if (applicationId && secret) {
358
363
  // Look up application
@@ -420,7 +425,7 @@ export default createBackendPlugin({
420
425
  id: app.id,
421
426
  name: app.name,
422
427
  roles: roleIds,
423
- accessRulesArray,
428
+ accessRules: accessRulesArray,
424
429
  teamIds,
425
430
  };
426
431
  }
@@ -562,9 +567,15 @@ export default createBackendPlugin({
562
567
  const notificationClient = rpcClient.forPlugin(NotificationApi);
563
568
  const frontendUrl =
564
569
  process.env.BASE_URL || "http://localhost:5173";
565
- const resetUrl = `${frontendUrl}/auth/reset-password?token=${
566
- url.split("token=")[1] ?? ""
567
- }`;
570
+ // SECURITY: Use URL parsing instead of brittle string splitting
571
+ const parsedUrl = new URL(url);
572
+ const resetToken = parsedUrl.searchParams.get("token");
573
+ if (!resetToken) {
574
+ throw new APIError("BAD_REQUEST", {
575
+ message: "Malformed password reset URL: missing token parameter",
576
+ });
577
+ }
578
+ const resetUrl = `${frontendUrl}/auth/reset-password?token=${encodeURIComponent(resetToken)}`;
568
579
 
569
580
  void notificationClient.sendTransactional({
570
581
  userId: user.id,
package/src/router.ts CHANGED
@@ -8,7 +8,8 @@ import {
8
8
  type ConfigService,
9
9
  toJsonSchema,
10
10
  } from "@checkstack/backend-api";
11
- import { authContract, passwordSchema } from "@checkstack/auth-common";
11
+ import { authContract, passwordSchema, authAccess, pluginMetadata } from "@checkstack/auth-common";
12
+ import { qualifyAccessRuleId } from "@checkstack/common";
12
13
  import { hashPassword } from "better-auth/crypto";
13
14
  import * as schema from "./schema";
14
15
  import { eq, inArray, and } from "drizzle-orm";
@@ -117,6 +118,45 @@ function generateSecret(): string {
117
118
  return Array.from(array, (byte) => chars[byte % chars.length]).join("");
118
119
  }
119
120
 
121
+ async function assertTeamManagementAccess({
122
+ user,
123
+ teamId,
124
+ internalDb,
125
+ }: {
126
+ user: AuthUser | undefined;
127
+ teamId: string;
128
+ internalDb: SafeDatabase<typeof schema>;
129
+ }): Promise<void> {
130
+ // Services are trusted via middleware
131
+ if (user?.type === "service") return;
132
+
133
+ const hasGlobalManage =
134
+ user?.accessRules?.includes("*") ||
135
+ user?.accessRules?.includes(qualifyAccessRuleId(pluginMetadata, authAccess.teams.manage));
136
+
137
+ if (hasGlobalManage) return; // Global manage allows all teams
138
+
139
+ // Check if user is a manager of this specific team
140
+ if (isRealUser(user)) {
141
+ const [managerRecord] = await internalDb
142
+ .select()
143
+ .from(schema.teamManager)
144
+ .where(
145
+ and(
146
+ eq(schema.teamManager.teamId, teamId),
147
+ eq(schema.teamManager.userId, user.id),
148
+ ),
149
+ )
150
+ .limit(1);
151
+
152
+ if (managerRecord) return; // Is team manager
153
+ }
154
+
155
+ throw new ORPCError("FORBIDDEN", {
156
+ message: "You do not have permission to manage this team",
157
+ });
158
+ }
159
+
120
160
  export const createAuthRouter = (
121
161
  internalDb: SafeDatabase<typeof schema>,
122
162
  strategyRegistry: { getStrategies: () => AuthStrategy<unknown>[] },
@@ -1385,7 +1425,13 @@ export const createAuthRouter = (
1385
1425
 
1386
1426
  const updateTeam = os.updateTeam.handler(async ({ input, context }) => {
1387
1427
  const { id, name, description } = input;
1388
- // TODO: Check if user is manager or has teamsManage access
1428
+
1429
+ await assertTeamManagementAccess({
1430
+ user: context.user,
1431
+ teamId: id,
1432
+ internalDb,
1433
+ });
1434
+
1389
1435
  const updates: {
1390
1436
  name?: string;
1391
1437
  description?: string | null;
@@ -1403,6 +1449,11 @@ export const createAuthRouter = (
1403
1449
  });
1404
1450
 
1405
1451
  const deleteTeam = os.deleteTeam.handler(async ({ input: id, context }) => {
1452
+ await assertTeamManagementAccess({
1453
+ user: context.user,
1454
+ teamId: id,
1455
+ internalDb,
1456
+ });
1406
1457
  await internalDb.transaction(async (tx) => {
1407
1458
  await tx.delete(schema.userTeam).where(eq(schema.userTeam.teamId, id));
1408
1459
  await tx
@@ -1419,7 +1470,12 @@ export const createAuthRouter = (
1419
1470
  context.logger.info(`[auth-backend] Deleted team: ${id}`);
1420
1471
  });
1421
1472
 
1422
- const addUserToTeam = os.addUserToTeam.handler(async ({ input }) => {
1473
+ const addUserToTeam = os.addUserToTeam.handler(async ({ input, context }) => {
1474
+ await assertTeamManagementAccess({
1475
+ user: context.user,
1476
+ teamId: input.teamId,
1477
+ internalDb,
1478
+ });
1423
1479
  await internalDb
1424
1480
  .insert(schema.userTeam)
1425
1481
  .values({ userId: input.userId, teamId: input.teamId })
@@ -1427,7 +1483,12 @@ export const createAuthRouter = (
1427
1483
  });
1428
1484
 
1429
1485
  const removeUserFromTeam = os.removeUserFromTeam.handler(
1430
- async ({ input }) => {
1486
+ async ({ input, context }) => {
1487
+ await assertTeamManagementAccess({
1488
+ user: context.user,
1489
+ teamId: input.teamId,
1490
+ internalDb,
1491
+ });
1431
1492
  await internalDb
1432
1493
  .delete(schema.userTeam)
1433
1494
  .where(
@@ -1439,14 +1500,24 @@ export const createAuthRouter = (
1439
1500
  },
1440
1501
  );
1441
1502
 
1442
- const addTeamManager = os.addTeamManager.handler(async ({ input }) => {
1503
+ const addTeamManager = os.addTeamManager.handler(async ({ input, context }) => {
1504
+ await assertTeamManagementAccess({
1505
+ user: context.user,
1506
+ teamId: input.teamId,
1507
+ internalDb,
1508
+ });
1443
1509
  await internalDb
1444
1510
  .insert(schema.teamManager)
1445
1511
  .values({ userId: input.userId, teamId: input.teamId })
1446
1512
  .onConflictDoNothing();
1447
1513
  });
1448
1514
 
1449
- const removeTeamManager = os.removeTeamManager.handler(async ({ input }) => {
1515
+ const removeTeamManager = os.removeTeamManager.handler(async ({ input, context }) => {
1516
+ await assertTeamManagementAccess({
1517
+ user: context.user,
1518
+ teamId: input.teamId,
1519
+ internalDb,
1520
+ });
1450
1521
  await internalDb
1451
1522
  .delete(schema.teamManager)
1452
1523
  .where(
@@ -1577,7 +1648,12 @@ export const createAuthRouter = (
1577
1648
  );
1578
1649
 
1579
1650
  // No grants = global access applies
1580
- if (grants.length === 0) return { hasAccess: hasGlobalAccess };
1651
+ if (grants.length === 0) {
1652
+ // SECURITY NOTE: No team grants configured for this resource.
1653
+ // Defaulting to global access check. If this resource should be restricted,
1654
+ // configure team grants or enable 'teamOnly' on the resource access settings.
1655
+ return { hasAccess: hasGlobalAccess };
1656
+ }
1581
1657
 
1582
1658
  // Check resource-level settings for teamOnly
1583
1659
  const settingsRows = await internalDb
@@ -1685,7 +1761,10 @@ export const createAuthRouter = (
1685
1761
 
1686
1762
  return resourceIds.filter((id) => {
1687
1763
  const resourceGrants = grantsByResource.get(id) || [];
1688
- if (resourceGrants.length === 0) return hasGlobalAccess;
1764
+ if (resourceGrants.length === 0) {
1765
+ // No team grants configured — fall through to global access
1766
+ return hasGlobalAccess;
1767
+ }
1689
1768
  const isTeamOnly = teamOnlyByResource.get(id) ?? false;
1690
1769
  if (!isTeamOnly && hasGlobalAccess) return true;
1691
1770
  return resourceGrants.some(