Skip to content

Commit d20ecb8

Browse files
committed
Release v0.6.24: security fixes (SSRF, auth, tunnels, agents, email), bump versions
1 parent fb9b2f8 commit d20ecb8

24 files changed

+551
-252
lines changed

apps/agent/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "agent",
3-
"version": "0.6.23",
3+
"version": "0.6.24",
44
"private": true,
55
"bin": {
66
"connect": "./dist/cli.js"

apps/api/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
{
22
"name": "api",
3-
"version": "0.6.23",
3+
"version": "0.6.24",
44
"private": true,
55
"scripts": {
66
"dev": "nest start --watch",
77
"build": "nest build",
88
"start": "node dist/main",
99
"start:prod": "node dist/main",
1010
"db:generate": "prisma generate",
11+
"db:migrate": "prisma migrate deploy",
1112
"db:push": ". ./.env 2>/dev/null; DATABASE_URL=${DATABASE_URL_MIGRATE:-$DATABASE_URL} prisma db push",
1213
"db:seed": "tsx prisma/seed.ts",
1314
"db:dangerous-reset": "echo '⚠️ This will DELETE ALL DATA. Press Ctrl+C to cancel.' && sleep 3 && . ./.env 2>/dev/null; DATABASE_URL=${DATABASE_URL_MIGRATE:-$DATABASE_URL} prisma db push --force-reset && npm run db:seed",

apps/api/src/agents/agents.controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -659,9 +659,9 @@ export class AgentsController {
659659
};
660660
}
661661

662-
// Send command as a message to the agent
662+
// Send command as a message to the agent (fromAgentId must be a valid Agent id; use target agent for system commands)
663663
const message = await this.agentsService.sendMessage(
664-
req.workspace.id, // Using workspace as pseudo-sender for system commands
664+
id,
665665
id,
666666
req.workspace.id,
667667
{

apps/api/src/agents/agents.service.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,13 @@ export class AgentsService {
212212
expiresAt?: Date;
213213
error?: string;
214214
}> {
215-
// First validate the current token
215+
// First validate the current token (no workspace context yet - use withoutRls)
216216
const tokenHash = this.hashToken(currentToken);
217-
const agent = await this.prisma.agent.findUnique({
218-
where: { id: agentId },
219-
});
217+
const agent = await this.prisma.withoutRls(() =>
218+
this.prisma.agent.findUnique({
219+
where: { id: agentId },
220+
})
221+
);
220222

221223
if (!agent) {
222224
return { success: false, error: 'Agent not found' };
@@ -231,13 +233,15 @@ export class AgentsService {
231233
const newTokenHash = this.hashToken(newToken);
232234
const newExpiresAt = calculateTokenExpiry();
233235

234-
await this.prisma.agent.update({
235-
where: { id: agentId },
236-
data: {
237-
tokenHash: newTokenHash,
238-
tokenExpiresAt: newExpiresAt,
239-
},
240-
});
236+
await this.prisma.withoutRls(() =>
237+
this.prisma.agent.update({
238+
where: { id: agentId },
239+
data: {
240+
tokenHash: newTokenHash,
241+
tokenExpiresAt: newExpiresAt,
242+
},
243+
})
244+
);
241245

242246
// Log rotation event
243247
await this.logAuditEvent(agentId, AgentAuditEvent.ROTATED, undefined, undefined, {
@@ -506,11 +510,13 @@ export class AgentsService {
506510
return null;
507511
}
508512

509-
// Find the agent and verify it belongs to the workspace
510-
const agent = await this.prisma.agent.findUnique({
511-
where: { id: agentId },
512-
include: { workspace: true },
513-
});
513+
// Find the agent and verify it belongs to the workspace (no workspace context yet - use withoutRls)
514+
const agent = await this.prisma.withoutRls(() =>
515+
this.prisma.agent.findUnique({
516+
where: { id: agentId },
517+
include: { workspace: true },
518+
})
519+
);
514520

515521
if (!agent || agent.workspaceId !== workspace.id) {
516522
return null;

apps/api/src/ai/ai.controller.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,17 +248,17 @@ export class AIController {
248248
throw new BadRequestException('Session belongs to different workspace');
249249
}
250250

251-
// Build traffic context
251+
// Redact if cloud provider (messages and traffic context must both be redacted before sending to third-party LLMs)
252+
const shouldRedact = config.provider !== 'ollama';
252253
const trafficContext = {
253254
packets: session.packets.map(p => ({
254255
direction: p.direction,
255256
protocol: p.protocol,
256-
parsed: p.parsed ? JSON.parse(p.parsed) : undefined,
257+
parsed: p.parsed
258+
? JSON.parse(shouldRedact ? this.aiService.redactPII(p.parsed) : p.parsed)
259+
: undefined,
257260
})),
258261
};
259-
260-
// Redact if cloud provider
261-
const shouldRedact = config.provider !== 'ollama';
262262
const messages = shouldRedact
263263
? body.messages.map(m => ({ ...m, content: this.aiService.redactPII(m.content) }))
264264
: body.messages;

apps/api/src/auth/auth.controller.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -202,16 +202,8 @@ export class AuthController {
202202
return this.authService.getCurrentUser(token);
203203
}
204204

205+
/** Use req.ip (set by Express when trust proxy is enabled) so rate limiting cannot be bypassed via spoofed X-Forwarded-For. */
205206
private getClientIp(req: Request): string {
206-
const forwarded =
207-
req.headers['x-forwarded-for'] ||
208-
req.headers['x-real-ip'] ||
209-
req.ip ||
210-
req.connection?.remoteAddress ||
211-
req.socket?.remoteAddress ||
212-
'unknown';
213-
214-
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded.toString();
215-
return raw.split(',')[0].trim();
207+
return req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress || 'unknown';
216208
}
217209
}

apps/api/src/auth/auth.service.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,10 @@ export class AuthService {
152152
});
153153
}
154154

155-
// Login: send magic link to existing user
155+
// Login: send magic link to existing user (same response whether account exists or not — prevents user enumeration)
156156
async login(email: string): Promise<{ message: string }> {
157157
const normalizedEmail = email.toLowerCase().trim();
158+
const genericMessage = 'If an account exists with this email, you will receive a magic link';
158159

159160
// Login is unauthenticated — bypass RLS
160161
return this.prisma.withoutRls(async () => {
@@ -163,7 +164,7 @@ export class AuthService {
163164
});
164165

165166
if (!user) {
166-
throw new BadRequestException('No account found with this email. Please register first.');
167+
return { message: genericMessage };
167168
}
168169

169170
// Invalidate any existing unused magic links
@@ -197,7 +198,7 @@ export class AuthService {
197198
type: 'LOGIN',
198199
});
199200

200-
return { message: 'If an account exists with this email, you will receive a magic link' };
201+
return { message: genericMessage };
201202
});
202203
}
203204

apps/api/src/auth/device.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export class DeviceService {
5555
},
5656
});
5757

58-
const baseUrl = process.env.WEB_URL || 'http://localhost:3000';
58+
const baseUrl = process.env.WEB_URL || process.env.APP_URL || 'http://localhost:3000';
5959

6060
return {
6161
deviceCode,

apps/api/src/auth/email.service.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export class EmailService {
2727

2828
async sendMagicLink(options: SendMagicLinkOptions): Promise<void> {
2929
const { to, token, type, workspaceName } = options;
30-
const baseUrl = process.env.APP_URL || 'http://localhost:3000';
30+
const baseUrl = process.env.WEB_URL || process.env.APP_URL || 'http://localhost:3000';
3131
const magicLink = `${baseUrl}/verify?token=${token}`;
3232

3333
const subject = type === 'REGISTER'
@@ -54,8 +54,12 @@ This link expires in 15 minutes.
5454
5555
If you didn't request this, you can safely ignore this email.`;
5656

57-
// In development or if Resend is not configured, log to console
57+
// Resend not configured
5858
if (!this.resend) {
59+
if (process.env.NODE_ENV === 'production') {
60+
throw new Error('Email service not configured. Please set RESEND_API_KEY.');
61+
}
62+
// Development only: log to console (never log magic links in production)
5963
this.logger.log('');
6064
this.logger.log('═══════════════════════════════════════════════════════════');
6165
this.logger.log(' 📧 MAGIC LINK EMAIL (Development Mode)');
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { extractClientIp } from './security';
2+
3+
describe('extractClientIp', () => {
4+
it('returns the rightmost IP from X-Forwarded-For to prevent spoofing', () => {
5+
const headers = {
6+
'x-forwarded-for': '8.8.8.8, 1.2.3.4', // 8.8.8.8 is spoofed, 1.2.3.4 is real client IP added by proxy
7+
};
8+
const ip = extractClientIp(headers);
9+
expect(ip).toBe('1.2.3.4');
10+
});
11+
12+
it('returns the only IP when X-Forwarded-For has one entry', () => {
13+
const headers = { 'x-forwarded-for': '203.0.113.50' };
14+
expect(extractClientIp(headers)).toBe('203.0.113.50');
15+
});
16+
17+
it('prefers Cloudflare cf-connecting-ip when present', () => {
18+
const headers = {
19+
'cf-connecting-ip': '1.2.3.4',
20+
'x-forwarded-for': '8.8.8.8, 1.2.3.4',
21+
};
22+
expect(extractClientIp(headers)).toBe('1.2.3.4');
23+
});
24+
25+
it('returns undefined when no IP headers are present', () => {
26+
expect(extractClientIp({})).toBeUndefined();
27+
});
28+
});

0 commit comments

Comments
 (0)