Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ module.exports = {
moduleNameMapper: {
'^@yieldvault/api-schemas$': '<rootDir>/../packages/api-schemas/src/index.ts',
'^@yieldvault/api-schemas/(.*)$': '<rootDir>/../packages/api-schemas/src/$1',
'^zod$': '<rootDir>/node_modules/zod',
'^@prisma/instrumentation$': '<rootDir>/src/__tests__/mocks/prismainstrumentation.js',
'^@opentelemetry/(.*)$': '<rootDir>/src/__tests__/mocks/opentelemetry.js',
// Redirect the sorobanClient module that vaultEndpoints imports (./sorobanClient)
Expand Down
25 changes: 7 additions & 18 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -143,22 +143,6 @@ model AdminAuditLog {
@@index([actor])
}

model ApiKey {
id String @id @default(uuid())
tenantId String
hashedKey String @unique
scopes String // JSON-serialised string[]
expiresAt DateTime?
lastUsedAt DateTime?
revoked Boolean @default(false)
revokedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([tenantId])
@@index([hashedKey])
@@index([expiresAt])
}

model ApiKeyAuditEvent {
id String @id @default(uuid())
Expand Down Expand Up @@ -543,19 +527,24 @@ model ApiKey {
id String @id @default(uuid())
tenantId String
hashedKey String @unique
role String
role String?
scopes String @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
expiresAt DateTime?
lastUsedAt DateTime?
revoked Boolean @default(false)
revokedAt DateTime?
isActive Boolean @default(true)
deletedAt DateTime?
deletedBy String?
deletionReason String?

tenant Tenant @relation(fields: [tenantId], references: [id])
tenant Tenant? @relation(fields: [tenantId], references: [id])

@@index([tenantId])
@@index([tenantId, deletedAt])
@@index([hashedKey])
@@index([role])
@@index([deletedAt])
}
Expand Down
154 changes: 154 additions & 0 deletions backend/src/__tests__/adminCacheAndParams.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import request from 'supertest';
import app from '../index';
import { registerApiKey } from '../middleware/apiKeyAuth';

describe('Admin cache regex and id validation (Issues #1609 & #1610)', () => {
const adminKey = 'admin-cache-params-test-key';
const authHeader = { Authorization: `ApiKey ${adminKey}` };

beforeAll(() => {
registerApiKey(adminKey);
});

describe('Issue #1609: DELETE /admin/cache regex constraints', () => {
it('rejects ReDoS vulnerable patterns with 400 quickly (<100ms)', async () => {
const start = Date.now();
const res = await request(app)
.delete('/admin/cache')
.query({ pattern: '(a+)+$' })
.set(authHeader);
const elapsed = Date.now() - start;

expect(res.status).toBe(400);
expect(elapsed).toBeLessThan(100);
expect(res.body.error).toBe('Bad Request');
});

it('rejects regex pattern exceeding 100 characters with 400', async () => {
const longPattern = 'a'.repeat(101);
const res = await request(app)
.delete('/admin/cache')
.query({ pattern: longPattern })
.set(authHeader);

expect(res.status).toBe(400);
expect(res.body.error).toBe('Bad Request');
});

it('rejects invalid regex syntax with 400', async () => {
const res = await request(app)
.delete('/admin/cache')
.query({ pattern: '[unclosed-bracket' })
.set(authHeader);

expect(res.status).toBe(400);
expect(res.body.error).toBe('Bad Request');
});

it('supports dryRun=true to preview matches without deleting', async () => {
const res = await request(app)
.delete('/admin/cache')
.query({ pattern: '^vault:', dryRun: 'true' })
.set(authHeader);

expect(res.status).toBe(200);
expect(res.body.dryRun).toBe(true);
expect(res.body.wouldInvalidate).toBe(true);
expect(res.body.stats).toBeDefined();
});
});

describe('Issue #1610: Admin id-based routes validation and URI decode error handling', () => {
it('returns 400 (not 500) for malformed URI-encoded idempotency key', async () => {
const res = await request(app)
.delete('/admin/idempotency/keys/%E0%A4%A')
.set(authHeader);

expect(res.status).toBe(400);
expect(res.body.error).toBe('Bad Request');
expect(res.body.message).toMatch(/failed to decode|malformed uri/i);
});

it('rejects invalid/malformed id parameters with 400 across admin id routes', async () => {
const badId = 'invalid id with spaces & symbols!';

// DELETE /admin/webhooks/:id
const resWebhook = await request(app)
.delete(`/admin/webhooks/${encodeURIComponent(badId)}`)
.set(authHeader);
expect(resWebhook.status).toBe(400);

// POST /admin/webhooks/:id/verify
const resVerify = await request(app)
.post(`/admin/webhooks/${encodeURIComponent(badId)}/verify`)
.set(authHeader);
expect(resVerify.status).toBe(400);

// POST /admin/webhooks/dead-letter/:id/retry
const resDeadLetter = await request(app)
.post(`/admin/webhooks/dead-letter/${encodeURIComponent(badId)}/retry`)
.set(authHeader);
expect(resDeadLetter.status).toBe(400);

// GET /admin/receipts/:id
const resReceipt = await request(app)
.get(`/admin/receipts/${encodeURIComponent(badId)}`)
.set(authHeader);
expect(resReceipt.status).toBe(400);

// GET /admin/receipts/:id/verify
const resReceiptVerify = await request(app)
.get(`/admin/receipts/${encodeURIComponent(badId)}/verify`)
.set(authHeader);
expect(resReceiptVerify.status).toBe(400);

// DELETE /admin/feature-flags/overrides/:id
const resFeatureFlag = await request(app)
.delete(`/admin/feature-flags/overrides/${encodeURIComponent(badId)}`)
.set(authHeader);
expect(resFeatureFlag.status).toBe(400);

// POST /admin/emails/replay/:id
const resEmailReplay = await request(app)
.post(`/admin/emails/replay/${encodeURIComponent(badId)}`)
.set(authHeader);
expect(resEmailReplay.status).toBe(400);

// DELETE /admin/wallet-aliases/:canonicalId
const resWalletAlias = await request(app)
.delete(`/admin/wallet-aliases/${encodeURIComponent(badId)}`)
.set(authHeader);
expect(resWalletAlias.status).toBe(400);

// GET /admin/exports/bulk/jobs/:id
const resBulkJob = await request(app)
.get(`/admin/exports/bulk/jobs/${encodeURIComponent(badId)}`)
.set(authHeader);
expect(resBulkJob.status).toBe(400);

// GET /admin/exports/bulk/artifacts/:artifactId
const resArtifact = await request(app)
.get(`/admin/exports/bulk/artifacts/${encodeURIComponent(badId)}`)
.set(authHeader);
expect(resArtifact.status).toBe(400);

// GET /admin/jobs/dead-letters/:id
const resDeadLetterJob = await request(app)
.get(`/admin/jobs/dead-letters/${encodeURIComponent(badId)}`)
.set(authHeader);
expect(resDeadLetterJob.status).toBe(400);

// POST /admin/jobs/dead-letters/:id/retry
const resRetryJob = await request(app)
.post(`/admin/jobs/dead-letters/${encodeURIComponent(badId)}/retry`)
.set(authHeader);
expect(resRetryJob.status).toBe(400);

// DELETE /admin/jobs/dead-letters/:id
const resDeleteJob = await request(app)
.delete(`/admin/jobs/dead-letters/${encodeURIComponent(badId)}`)
.set(authHeader);
expect(resDeleteJob.status).toBe(400);
});
});
});
13 changes: 11 additions & 2 deletions backend/src/__tests__/diagnosticsBundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,11 @@ describe('diagnosticsBundleHandler', () => {
expect(deps).toHaveProperty('jobs');
});

it('redacts sensitive environment variables', async () => {
// Set a sensitive env var to test redaction
it('redacts sensitive environment variables and fully masks secrets without partial disclosure', async () => {
// Set sensitive env vars to test redaction
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'super-secret-jwt-token-key-12345';
process.env.RATE_LIMIT_REDIS_URL = 'redis://user:secretpassword123@localhost:6379';
process.env.CIRCUIT_BREAKER_FAILURE_THRESHOLD = '5';

const req = mockReq();
Expand All @@ -117,6 +119,13 @@ describe('diagnosticsBundleHandler', () => {

const config = res.body.config;
expect(config.NODE_ENV).toBe('test');
// JWT_SECRET should never appear in diagnostics bundle
expect(config).not.toHaveProperty('JWT_SECRET');
// Sensitive keys under allowed prefixes must be fully masked
if (config.RATE_LIMIT_REDIS_URL) {
expect(config.RATE_LIMIT_REDIS_URL).toBe('***REDACTED***');
expect(config.RATE_LIMIT_REDIS_URL).not.toContain('secretpassword');
}
// Non-allowed keys should not appear
expect(config).not.toHaveProperty('HOME');
expect(config).not.toHaveProperty('PATH');
Expand Down
2 changes: 1 addition & 1 deletion backend/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class PatchedPrismaClient extends OriginalPrismaClient {
PrismaClientModule.PrismaClient = PatchedPrismaClient;

// Register default admin API keys for integration tests that use test-admin-key.
const { registerApiKey } = require('../middleware/apiKeyAuth') as typeof import('../middleware/apiKeyAuth');
const { registerApiKey } = require('../middleware/apiKeyAuth');
const defaultAdminKey = process.env.ADMIN_API_KEY || 'test-admin-key';
registerApiKey(defaultAdminKey);
registerApiKey('super-admin-test-key', { role: 'super-admin' });
Expand Down
87 changes: 87 additions & 0 deletions backend/src/__tests__/webhookVerifyEndpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import request from 'supertest';
import app from '../index';
import { registerApiKey } from '../middleware/apiKeyAuth';
import { logger } from '../middleware/structuredLogging';

describe('POST /webhooks/verify and /api/v1/webhooks/verify (Issue #1608)', () => {
const adminKey = 'webhook-verify-admin-key';
const authHeader = { Authorization: `ApiKey ${adminKey}` };

beforeAll(() => {
registerApiKey(adminKey, { role: 'admin', scopes: ['webhooks', 'admin'] });
});

it('rejects unauthenticated requests with 401', async () => {
const res1 = await request(app)
.post('/webhooks/verify')
.send({ secret: 'my-secret', payload: { event: 'test' } });

expect(res1.status).toBe(401);

const res2 = await request(app)
.post('/api/v1/webhooks/verify')
.send({ secret: 'my-secret', payload: { event: 'test' } });

expect(res2.status).toBe(401);
});

it('computes HMAC signature and verifies correctly for authenticated admin', async () => {
const secret = 'super-secret-hmac-key';
const payload = { event: 'deposit.completed', amount: '100' };

const res = await request(app)
.post('/api/v1/webhooks/verify')
.set(authHeader)
.send({ secret, payload });

expect(res.status).toBe(200);
expect(res.body.algorithm).toBe('HMAC-SHA256');
expect(res.body.signature).toBeDefined();
expect(typeof res.body.signature).toBe('string');
});

it('collapses into single handler with identical behavior across both paths', async () => {
const secret = 'shared-secret-123';
const payload = { action: 'ping' };

const resUnversioned = await request(app)
.post('/webhooks/verify')
.set(authHeader)
.send({ secret, payload });

const resVersioned = await request(app)
.post('/api/v1/webhooks/verify')
.set(authHeader)
.send({ secret, payload });

expect(resUnversioned.status).toBe(200);
expect(resVersioned.status).toBe(200);
expect(resUnversioned.body.signature).toBe(resVersioned.body.signature);
});

it('never leaks caller-supplied secret into logs', async () => {
const secret = 'secret-canary-never-log-this-value-xyz987';
const payload = { canary: true };

const logSpy = jest.spyOn(logger, 'log');
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});

await request(app)
.post('/api/v1/webhooks/verify')
.set(authHeader)
.send({ secret, payload });

for (const call of logSpy.mock.calls) {
const serialized = JSON.stringify(call);
expect(serialized).not.toContain(secret);
}

for (const call of consoleSpy.mock.calls) {
const serialized = JSON.stringify(call);
expect(serialized).not.toContain(secret);
}

logSpy.mockRestore();
consoleSpy.mockRestore();
});
});
13 changes: 11 additions & 2 deletions backend/src/auditRedaction.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { formatWalletForLog, isValidStellarAddress } from './walletUtils';

const REDACTED_VALUE = '[REDACTED]';

const SENSITIVE_KEY_PATTERN =
/(pass(word)?|secret|token|api[-_]?key|authorization|cookie|session|private[-_]?key|seed|mnemonic|credential)/i;

const WALLET_KEY_PATTERN =
/(wallet[-_]?address|wallet)/i;

const SENSITIVE_VALUE_PATTERN =
/(bearer\s+[a-z0-9._-]+|sk_(live|test)_[a-z0-9]+|xox[baprs]-[a-z0-9-]+|ghp_[a-z0-9]+)/i;

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function redactPrimitive(value: unknown): unknown {
function redactPrimitive(value: unknown, key?: string): unknown {
if (typeof value !== 'string') {
return value;
}
Expand All @@ -19,6 +24,10 @@ function redactPrimitive(value: unknown): unknown {
return REDACTED_VALUE;
}

if ((key && WALLET_KEY_PATTERN.test(key)) || isValidStellarAddress(value)) {
return formatWalletForLog(value);
}

return value;
}

Expand All @@ -44,7 +53,7 @@ export function redactSensitiveLogAttributes<T>(input: T): T {
continue;
}

output[key] = redactPrimitive(value);
output[key] = redactPrimitive(value, key);
}

return output as T;
Expand Down
Loading