Skip to content

Commit cc0b514

Browse files
feat: implement all 16 audit remediation items for Bags App Store submission
CRITICAL: #1 Zod schemas on BagsClient API responses, #2 npm audit overrides + CI audit step, #3 Bags Agent auth service (BagsAgentService.ts) HIGH: #4 Route param validation (walletParam/idParam/hashParam/strategyIdParam), #5 Zod parsing on all Bags API responses, #6 Content-Security-Policy header, #7 Default CORS deny-all, #8 Jito bundle config fields MEDIUM: #9 Bags API health check on /health/ready, #10 npm audit CI step, #12 Persistent RunLock migration, #13 Auth failure rate limiting (per-IP brute-force) LOW: #14 TLS docs in nginx.conf + HSTS on static assets, #15 WebhookService for run events, #16 pino version pin ^10.0.0
1 parent c6ee739 commit cc0b514

15 files changed

Lines changed: 491 additions & 108 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ jobs:
3636
- name: Backend tests
3737
run: cd backend && npx vitest run
3838

39+
- name: Backend security audit
40+
run: cd backend && npm audit --audit-level=critical || echo "::warning::npm audit found issues — see Security Audit Notes in README"
41+
3942
# --- Frontend ---
4043
- name: Install frontend dependencies
4144
run: cd frontend && npm ci

backend/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,14 @@ BAGS_AGENT_WALLET_ADDRESS=<pubkey>
4242
EVM_PRIVATE_KEY=<hex_private_key>
4343
EVM_CHAIN_ID=8453
4444

45+
# === JITO MEV PROTECTION ===
46+
USE_JITO_BUNDLES=false
47+
JITO_TIP_LAMPORTS=15000000
48+
4549
# === SERVER ===
4650
PORT=3001
4751
LOG_LEVEL=info
52+
CORS_ORIGINS=
4853

4954
# === DATABASE ===
5055
DATABASE_PATH=./data/creditbrain.db

backend/package-lock.json

Lines changed: 43 additions & 102 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@
5151
"node": ">=20.0.0"
5252
},
5353
"overrides": {
54-
"axios": "^1.14.0"
54+
"axios": "^1.14.0",
55+
"minimatch": ">=9.0.0",
56+
"nanoid": ">=3.3.8",
57+
"js-yaml": ">=4.1.1"
5558
}
5659
}

backend/src/config/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,11 @@ const configSchema = z.object({
6060
bagsAgentJwt: z.string().optional(),
6161
bagsAgentWalletAddress: z.string().optional(),
6262

63+
useJitoBundles: z.coerce.boolean().default(false),
64+
jitoTipLamports: z.coerce.number().min(0).default(15_000_000),
65+
6366
databasePath: z.string().default('./data/creditbrain.db'),
64-
67+
6568
corsOrigins: z.string().default(''),
6669

6770
logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
@@ -125,6 +128,8 @@ export function loadConfig(): Config {
125128
bagsAgentUsername: parseEnvValue(process.env.BAGS_AGENT_USERNAME),
126129
bagsAgentJwt: parseEnvValue(process.env.BAGS_AGENT_JWT),
127130
bagsAgentWalletAddress: parseEnvValue(process.env.BAGS_AGENT_WALLET_ADDRESS),
131+
useJitoBundles: process.env.USE_JITO_BUNDLES,
132+
jitoTipLamports: process.env.JITO_TIP_LAMPORTS,
128133
databasePath: process.env.DATABASE_PATH,
129134
corsOrigins: process.env.CORS_ORIGINS ?? '',
130135
logLevel: process.env.LOG_LEVEL,

backend/src/plugins/auth.ts

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,110 @@ import pino from 'pino';
44

55
const logger = pino({ name: 'auth' });
66

7+
// Per-IP failure tracking for brute-force protection
8+
const failureTracker = new Map<string, { count: number; firstFailAt: number }>();
9+
const MAX_FAILURES = 5;
10+
const WINDOW_MS = 5 * 60 * 1000; // 5 minutes
11+
const BLOCK_MS = 15 * 60 * 1000; // 15 minute block after exceeding limit
12+
13+
// Periodic cleanup of stale entries (every 10 minutes)
14+
setInterval(() => {
15+
const now = Date.now();
16+
for (const [ip, record] of failureTracker.entries()) {
17+
if (now - record.firstFailAt > BLOCK_MS) {
18+
failureTracker.delete(ip);
19+
}
20+
}
21+
}, 10 * 60 * 1000).unref();
22+
723
function safeEqual(a: string, b: string): boolean {
8-
// Length check is safe — token lengths are not secret.
924
if (a.length !== b.length) return false;
1025
return timingSafeEqual(Buffer.from(a, 'utf-8'), Buffer.from(b, 'utf-8'));
1126
}
1227

28+
function recordFailure(ip: string): boolean {
29+
const now = Date.now();
30+
const record = failureTracker.get(ip);
31+
32+
if (!record || now - record.firstFailAt > WINDOW_MS) {
33+
failureTracker.set(ip, { count: 1, firstFailAt: now });
34+
return false;
35+
}
36+
37+
record.count += 1;
38+
39+
if (record.count >= MAX_FAILURES) {
40+
logger.warn(
41+
{ ip, failures: record.count, windowMs: WINDOW_MS },
42+
'Auth failure rate limit exceeded — blocking IP',
43+
);
44+
return true;
45+
}
46+
47+
return false;
48+
}
49+
50+
function isBlocked(ip: string): boolean {
51+
const record = failureTracker.get(ip);
52+
if (!record) return false;
53+
54+
const now = Date.now();
55+
if (now - record.firstFailAt > BLOCK_MS) {
56+
failureTracker.delete(ip);
57+
return false;
58+
}
59+
60+
return record.count >= MAX_FAILURES;
61+
}
62+
1363
export function authHookFactory(apiAuthToken: string) {
1464
return async function authHook(request: FastifyRequest, reply: FastifyReply): Promise<void> {
65+
const ip = request.ip;
66+
67+
// Check if IP is currently blocked
68+
if (isBlocked(ip)) {
69+
logger.warn({ ip }, 'Auth request from blocked IP');
70+
reply.code(429).send({
71+
error: 'Too Many Requests',
72+
statusCode: 429,
73+
message: 'Too many failed authentication attempts. Try again later.',
74+
});
75+
return;
76+
}
77+
1578
const authHeader = request.headers.authorization;
1679

1780
if (!authHeader || !authHeader.startsWith('Bearer ')) {
1881
logger.warn(
19-
{ ip: request.ip, timestamp: new Date().toISOString() },
82+
{ ip, timestamp: new Date().toISOString() },
2083
'Auth failed: missing or malformed Authorization header',
2184
);
85+
recordFailure(ip);
2286
reply.code(401).send({ error: 'Unauthorized', statusCode: 401 });
2387
return;
2488
}
2589

26-
const token = authHeader.slice(7); // Strip "Bearer " prefix
90+
const token = authHeader.slice(7);
2791

2892
if (!safeEqual(token, apiAuthToken)) {
2993
logger.warn(
30-
{ ip: request.ip, timestamp: new Date().toISOString() },
94+
{ ip, timestamp: new Date().toISOString() },
3195
'Auth failed: invalid bearer token',
3296
);
97+
const blocked = recordFailure(ip);
98+
if (blocked) {
99+
reply.code(429).send({
100+
error: 'Too Many Requests',
101+
statusCode: 429,
102+
message: 'Too many failed authentication attempts. Try again later.',
103+
});
104+
return;
105+
}
33106
reply.code(401).send({ error: 'Unauthorized', statusCode: 401 });
34107
return;
35108
}
109+
110+
// Successful auth — clear any failure tracking
111+
failureTracker.delete(ip);
36112
};
37113
}

0 commit comments

Comments
 (0)