Skip to content

Commit 6ccca67

Browse files
authored
Merge pull request #762 from Keengfk/fix/debug-secret-redaction
fix: redact secrets from debug diagnostic payloads
2 parents e0d9936 + 3411c19 commit 6ccca67

4 files changed

Lines changed: 129 additions & 3 deletions

File tree

backend/src/api/debug.routes.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,6 @@ debugRouter.get('/debug/reflector-test', blockDebugInProduction, async (req: Req
125125
environment: {
126126
nodeEnv: global.process.env.NODE_ENV,
127127
apiKeySet: !!global.process.env.COINGECKO_API_KEY,
128-
apiKeyLength: global.process.env.COINGECKO_API_KEY?.length || 0
129128
}
130129
}
131130
return ok(res, redactObject(response))
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, it, expect, beforeAll, vi } from 'vitest'
2+
import express, { Express } from 'express'
3+
import request from 'supertest'
4+
5+
// Enable debug routes for all tests in this file
6+
vi.mock('../config/featureFlags.js', () => ({
7+
getFeatureFlags: () => ({ enableDebugRoutes: true })
8+
}))
9+
10+
// Mock requireAdmin to pass through
11+
vi.mock('../middleware/auth.js', () => ({
12+
requireAdmin: (_req: any, _res: any, next: any) => next()
13+
}))
14+
15+
// Mock adminRateLimiter to pass through
16+
vi.mock('../middleware/rateLimit.js', () => ({
17+
adminRateLimiter: (_req: any, _res: any, next: any) => next()
18+
}))
19+
20+
// Mock reflector service — factory must be self-contained (vi.mock is hoisted)
21+
vi.mock('../services/reflector.js', () => {
22+
const instance = {
23+
clearCache: () => undefined,
24+
getCacheStatus: () => ({ cached: false }),
25+
getCurrentPricesWithMeta: async () => ({ prices: { XLM: 0.1 }, feedMeta: {} }),
26+
testApiConnectivity: async () => ({ ok: true }),
27+
}
28+
return { ReflectorService: function () { return instance } }
29+
})
30+
31+
// Mock runtimeServices
32+
vi.mock('../services/runtimeServices.js', () => ({
33+
autoRebalancer: null
34+
}))
35+
36+
// Mock portfolioStorage
37+
vi.mock('../services/portfolioStorage.js', () => ({
38+
portfolioStorage: { getPortfolioCount: async () => 0 }
39+
}))
40+
41+
// Mock notificationService
42+
vi.mock('../services/notificationService.js', () => ({
43+
notificationService: {
44+
getPreferences: () => ({
45+
emailEnabled: true,
46+
emailAddress: 'user@example.com',
47+
webhookEnabled: true,
48+
webhookUrl: 'https://hooks.example.com/secret-token',
49+
}),
50+
notify: async () => undefined,
51+
}
52+
}))
53+
54+
let app: Express
55+
56+
beforeAll(async () => {
57+
app = express()
58+
app.use(express.json())
59+
const { debugRouter } = await import('../api/debug.routes.js')
60+
app.use('/api', debugRouter)
61+
})
62+
63+
describe('debug routes — secret redaction', () => {
64+
it('GET /debug/coingecko-test does not expose testUrl', async () => {
65+
const res = await request(app).get('/api/debug/coingecko-test')
66+
expect(res.status).toBe(200)
67+
expect(res.body.data).not.toHaveProperty('testUrl')
68+
expect(res.body.data).toHaveProperty('apiKeySet')
69+
expect(res.body.data).toHaveProperty('responseStatus')
70+
})
71+
72+
it('GET /debug/reflector-test does not expose apiKeyLength', async () => {
73+
const res = await request(app).get('/api/debug/reflector-test')
74+
expect(res.status).toBe(200)
75+
expect(res.body.data.environment).not.toHaveProperty('apiKeyLength')
76+
expect(res.body.data.environment).toHaveProperty('apiKeySet')
77+
})
78+
79+
it('POST /debug/notifications/test redacts email and webhook in sentTo', async () => {
80+
const res = await request(app)
81+
.post('/api/debug/notifications/test')
82+
.send({ userId: 'GUSER123', eventType: 'rebalance' })
83+
expect(res.status).toBe(200)
84+
expect(res.body.data.sentTo.email).toBe('[REDACTED]')
85+
expect(res.body.data.sentTo.webhook).toBe('[REDACTED]')
86+
})
87+
})

backend/src/test/secretRedactor.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { redactObject } from '../utils/secretRedactor.js'
2+
import { redactObject, redactString } from '../utils/secretRedactor.js'
33

44
describe('secretRedactor', () => {
55
it('redacts authorization header values', () => {
@@ -72,4 +72,40 @@ describe('secretRedactor', () => {
7272
expect(output.arrayValues[1].asset).toBe('XLM')
7373
expect(output.request.meta.retries).toBe(2)
7474
})
75+
76+
it('redacts webhookUrl and webhook fields', () => {
77+
const input = {
78+
sentTo: {
79+
webhookUrl: 'https://hooks.example.com/secret-token',
80+
webhook: 'https://hooks.example.com/other-token',
81+
},
82+
}
83+
const output = redactObject(input)
84+
expect((output.sentTo as any).webhookUrl).toBe('[REDACTED]')
85+
expect((output.sentTo as any).webhook).toBe('[REDACTED]')
86+
})
87+
88+
it('redacts email address fields', () => {
89+
const input = { emailAddress: 'user@example.com', smtpUser: 'smtp@example.com', smtpPass: 'hunter2' }
90+
const output = redactObject(input)
91+
expect((output as any).emailAddress).toBe('[REDACTED]')
92+
expect((output as any).smtpUser).toBe('[REDACTED]')
93+
expect((output as any).smtpPass).toBe('[REDACTED]')
94+
})
95+
96+
it('redacts Bearer tokens in strings', () => {
97+
expect(redactString('Authorization: Bearer abc123xyz')).toBe('Authorization: Bearer [REDACTED]')
98+
expect(redactString('no token here')).toBe('no token here')
99+
})
100+
101+
it('redacts Stellar secret keys in strings', () => {
102+
// Valid Stellar secret key: S + 55 chars from base32 alphabet [A-Z2-7]
103+
const stellarSecret = 'SABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'
104+
expect(redactString(stellarSecret)).toBe('[REDACTED]')
105+
})
106+
107+
it('redacts API keys in query params', () => {
108+
const url = 'https://api.example.com/data?api_key=supersecret&foo=bar'
109+
expect(redactString(url)).toBe('https://api.example.com/data?api_key=[REDACTED]&foo=bar')
110+
})
75111
})

backend/src/utils/secretRedactor.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const STELLAR_SECRET_REGEX = /S[A-Z2-7]{55}/g;
99
const CG_API_KEY_REGEX = /c?g-[a-zA-Z0-9_-]{10,}/g;
1010
// Matches general query parameter API keys: ?api_key=SECRET or &apikey=SECRET or ?x_cg_pro_api_key=SECRET
1111
const QUERY_PARAM_KEY_REGEX = /([?&](?:api_key|apikey|x_cg_pro_api_key|x_cg_demo_api_key)=)[^&]+/gi;
12+
// Matches Bearer tokens in Authorization header values
13+
const BEARER_TOKEN_REGEX = /\bBearer\s+\S+/gi;
1214

1315
const REDACTED_HINT = '[REDACTED]';
1416

@@ -53,7 +55,9 @@ export function redactString(str: string): string {
5355
// Redact explicit CoinGecko Keys if identifiable
5456
.replace(CG_API_KEY_REGEX, REDACTED_HINT)
5557
// Redact keys in URLs
56-
.replace(QUERY_PARAM_KEY_REGEX, `$1${REDACTED_HINT}`);
58+
.replace(QUERY_PARAM_KEY_REGEX, `$1${REDACTED_HINT}`)
59+
// Redact Bearer tokens
60+
.replace(BEARER_TOKEN_REGEX, `Bearer ${REDACTED_HINT}`);
5761

5862
return redacted;
5963
}

0 commit comments

Comments
 (0)