@checkstack/auth-backend 0.4.8 → 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 +21 -0
- package/package.json +3 -3
- package/src/api-key-parsing.test.ts +26 -0
- package/src/index.ts +18 -7
- package/src/router.ts +87 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
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
|
+
|
|
3
24
|
## 0.4.8
|
|
4
25
|
|
|
5
26
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/auth-backend",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"scripts": {
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@checkstack/auth-common": "0.5.5",
|
|
15
|
-
"@checkstack/backend-api": "0.
|
|
15
|
+
"@checkstack/backend-api": "0.8.0",
|
|
16
16
|
"@checkstack/notification-common": "0.2.5",
|
|
17
|
-
"@checkstack/command-backend": "0.1.
|
|
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",
|
|
@@ -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
|
|
354
|
-
|
|
355
|
-
const
|
|
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
|
-
|
|
566
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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(
|