From ed58d2999638df9e9f2473e5cb1a5fb7ce049299 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sun, 22 Mar 2026 18:00:11 +0000 Subject: [PATCH 1/2] feat(security): enforce 12-char minimum password and fix credential masking threshold --- src/api/routers/projects.ts | 2 +- src/api/routers/users.ts | 4 +- tests/unit/api/routers/projects.test.ts | 39 +++++++++++++++ tests/unit/api/routers/users.test.ts | 65 ++++++++++++++++++++++--- 4 files changed, 100 insertions(+), 10 deletions(-) diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts index b65f8a54..77f62db0 100644 --- a/src/api/routers/projects.ts +++ b/src/api/routers/projects.ts @@ -230,7 +230,7 @@ export const projectsRouter = router({ envVarKey: row.envVarKey, name: row.name, isConfigured: true, - maskedValue: row.value.length <= 4 ? '****' : `****${row.value.slice(-4)}`, + maskedValue: row.value.length <= 12 ? '****' : `****${row.value.slice(-4)}`, })); } catch (err) { // Decryption key missing/wrong — return metadata without value preview diff --git a/src/api/routers/users.ts b/src/api/routers/users.ts index d4637004..b81ab6af 100644 --- a/src/api/routers/users.ts +++ b/src/api/routers/users.ts @@ -23,7 +23,7 @@ export const usersRouter = router({ z.object({ email: z.string().email(), name: z.string().min(1), - password: z.string().min(1), + password: z.string().min(12), role: z.enum(['member', 'admin', 'superadmin']).optional(), }), ) @@ -56,7 +56,7 @@ export const usersRouter = router({ name: z.string().min(1).optional(), email: z.string().email().optional(), role: z.enum(['member', 'admin', 'superadmin']).optional(), - password: z.string().min(1).optional(), + password: z.string().min(12).optional(), }), ) .mutation(async ({ ctx, input }) => { diff --git a/tests/unit/api/routers/projects.test.ts b/tests/unit/api/routers/projects.test.ts index f46d22db..9ca23606 100644 --- a/tests/unit/api/routers/projects.test.ts +++ b/tests/unit/api/routers/projects.test.ts @@ -495,6 +495,45 @@ describe('projectsRouter', () => { level: 'warning', }); }); + + it('masks credential with exactly 11 chars (short — shows ****)', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + // 11 chars: 'abcdefghijk' + mockListProjectCredentials.mockResolvedValue([ + { envVarKey: 'KEY_11', name: null, value: 'abcdefghijk' }, + ]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.credentials.list({ projectId: 'p1' }); + + expect(result[0].maskedValue).toBe('****'); + }); + + it('masks credential with exactly 12 chars (boundary — shows ****)', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + // 12 chars: 'abcdefghijkl' + mockListProjectCredentials.mockResolvedValue([ + { envVarKey: 'KEY_12', name: null, value: 'abcdefghijkl' }, + ]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.credentials.list({ projectId: 'p1' }); + + expect(result[0].maskedValue).toBe('****'); + }); + + it('masks credential with exactly 13 chars (long — shows last 4 chars)', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + // 13 chars: 'abcdefghijklm' + mockListProjectCredentials.mockResolvedValue([ + { envVarKey: 'KEY_13', name: null, value: 'abcdefghijklm' }, + ]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.credentials.list({ projectId: 'p1' }); + + expect(result[0].maskedValue).toBe('****jklm'); + }); }); describe('set', () => { diff --git a/tests/unit/api/routers/users.test.ts b/tests/unit/api/routers/users.test.ts index 18a908d7..4200f6fa 100644 --- a/tests/unit/api/routers/users.test.ts +++ b/tests/unit/api/routers/users.test.ts @@ -131,10 +131,10 @@ describe('usersRouter', () => { const result = await caller.create({ email: 'newuser@example.com', name: 'New User', - password: 'secret123', + password: 'secret123456789', }); - expect(mockBcryptHash).toHaveBeenCalledWith('secret123', 10); + expect(mockBcryptHash).toHaveBeenCalledWith('secret123456789', 10); expect(mockCreateUser).toHaveBeenCalledWith({ orgId: 'org-1', email: 'newuser@example.com', @@ -152,7 +152,7 @@ describe('usersRouter', () => { await caller.create({ email: 'newadmin@example.com', name: 'New Admin', - password: 'secret123', + password: 'secret123456789', role: 'admin', }); @@ -166,7 +166,7 @@ describe('usersRouter', () => { caller.create({ email: 'superuser@example.com', name: 'Super User', - password: 'secret123', + password: 'secret123456789', role: 'superadmin', }), ).rejects.toMatchObject({ code: 'FORBIDDEN' }); @@ -181,13 +181,45 @@ describe('usersRouter', () => { await caller.create({ email: 'super2@example.com', name: 'Super 2', - password: 'secret123', + password: 'secret123456789', role: 'superadmin', }); expect(mockCreateUser).toHaveBeenCalledWith(expect.objectContaining({ role: 'superadmin' })); }); + it('rejects password shorter than 12 characters', async () => { + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await expect( + caller.create({ email: 'x@example.com', name: 'X', password: 'short' }), + ).rejects.toThrow(); + + expect(mockCreateUser).not.toHaveBeenCalled(); + }); + + it('accepts password of exactly 12 characters', async () => { + mockCreateUser.mockResolvedValue({ id: 'new-user-1' }); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await caller.create({ email: 'x@example.com', name: 'X', password: 'exactly12chr' }); + + expect(mockCreateUser).toHaveBeenCalled(); + }); + + it('accepts password longer than 12 characters', async () => { + mockCreateUser.mockResolvedValue({ id: 'new-user-2' }); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await caller.create({ + email: 'x@example.com', + name: 'X', + password: 'this-is-a-very-long-password-123', + }); + + expect(mockCreateUser).toHaveBeenCalled(); + }); + it('throws UNAUTHORIZED when not authenticated', async () => { const caller = createCaller({ user: null, effectiveOrgId: null }); await expect( @@ -233,9 +265,9 @@ describe('usersRouter', () => { mockUpdateUser.mockResolvedValue(undefined); const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); - await caller.update({ id: 'user-2', password: 'newpassword' }); + await caller.update({ id: 'user-2', password: 'newpassword12' }); - expect(mockBcryptHash).toHaveBeenCalledWith('newpassword', 10); + expect(mockBcryptHash).toHaveBeenCalledWith('newpassword12', 10); expect(mockUpdateUser).toHaveBeenCalledWith('user-2', { passwordHash: 'hashed-password' }); }); @@ -337,6 +369,25 @@ describe('usersRouter', () => { expect(mockUpdateUser).toHaveBeenCalledWith('user-2', { role: 'admin' }); }); + it('rejects update password shorter than 12 characters', async () => { + mockGetUserById.mockResolvedValue({ id: 'user-2', orgId: 'org-1', role: 'member' }); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await expect(caller.update({ id: 'user-2', password: 'tooshort' })).rejects.toThrow(); + + expect(mockUpdateUser).not.toHaveBeenCalled(); + }); + + it('accepts update password of exactly 12 characters', async () => { + mockGetUserById.mockResolvedValue({ id: 'user-2', orgId: 'org-1', role: 'member' }); + mockUpdateUser.mockResolvedValue(undefined); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await caller.update({ id: 'user-2', password: 'exactly12chr' }); + + expect(mockUpdateUser).toHaveBeenCalled(); + }); + it('throws UNAUTHORIZED when not authenticated', async () => { const caller = createCaller({ user: null, effectiveOrgId: null }); await expect(caller.update({ id: 'user-2', name: 'X' })).rejects.toMatchObject({ From 3a401246f949e4be99831b91ae136c1f653166e8 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sun, 22 Mar 2026 18:09:09 +0000 Subject: [PATCH 2/2] fix(security): enforce 12-char minimum in CLI tool and frontend form - tools/create-admin-user.ts: add runtime check rejecting passwords shorter than 12 chars, matching the tRPC router validation; update doc comment examples to use a compliant password instead of 'changeme' (8 chars) - web/src/components/settings/user-form-dialog.tsx: add minLength={12} to the password input and update placeholder text so users see the requirement before submitting the form Co-Authored-By: Claude Opus 4.6 --- tools/create-admin-user.ts | 11 +++++++++-- web/src/components/settings/user-form-dialog.tsx | 5 ++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/tools/create-admin-user.ts b/tools/create-admin-user.ts index fba9816a..f08a81dd 100644 --- a/tools/create-admin-user.ts +++ b/tools/create-admin-user.ts @@ -3,11 +3,13 @@ * * Usage (local, with tsx): * node --env-file=.env --import tsx tools/create-admin-user.ts \ - * --email admin@example.com --password changeme --name "Admin" + * --email admin@example.com --password 'MySecurePass1!' --name "Admin" * * Inside Docker: * docker compose exec dashboard node dist/tools/create-admin-user.mjs \ - * --email admin@example.com --password changeme --name "Admin" + * --email admin@example.com --password 'MySecurePass1!' --name "Admin" + * + * Note: passwords must be at least 12 characters. */ import bcrypt from 'bcrypt'; @@ -34,6 +36,11 @@ function parseArgs(argv: string[]): { email: string; password: string; name: str process.exit(1); } + if (password.length < 12) { + console.error('Error: password must be at least 12 characters.'); + process.exit(1); + } + return { email, password, name }; } diff --git a/web/src/components/settings/user-form-dialog.tsx b/web/src/components/settings/user-form-dialog.tsx index 4e8863a0..8632c9c6 100644 --- a/web/src/components/settings/user-form-dialog.tsx +++ b/web/src/components/settings/user-form-dialog.tsx @@ -105,7 +105,10 @@ export function UserFormDialog({ open, onOpenChange, user }: UserFormDialogProps type="password" value={password} onChange={(e) => setPassword(e.target.value)} - placeholder={isEdit ? 'Enter new password to change' : 'Password'} + placeholder={ + isEdit ? 'Enter new password to change (min 12 chars)' : 'Minimum 12 characters' + } + minLength={12} required={!isEdit} />