Skip to content

Commit 437ef29

Browse files
committed
v0.6.8: security hardening
- Hash API keys with SHA-256 before storing (was plaintext) - Encrypt workspace aiApiKey with AES-256-GCM at rest - Remove API key from localStorage; web UI uses session cookie only - Add per-email rate limiting on magic-link endpoints (3/min) - Switch docker-compose DB credentials to env var substitution - Fix www redirect to use req.protocol instead of raw XFF header - Remove unsafe-eval from Swagger CSP - Gate debug startup logging behind NODE_ENV !== production - Strip internal state (dbConnected) from public health endpoint - Add SRI hash to Seline analytics script
1 parent 2a15adb commit 437ef29

24 files changed

+499
-100
lines changed

.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Docker Compose environment variables
2+
# Copy this file to .env and fill in values before running docker-compose up.
3+
# .env is gitignored — never commit it with real credentials.
4+
5+
# PostgreSQL credentials (used by docker-compose for the postgres service and api service)
6+
# POSTGRES_USER defaults to "privateconnect" if not set.
7+
POSTGRES_USER=privateconnect
8+
9+
# POSTGRES_PASSWORD is required — docker-compose will refuse to start without it.
10+
# Use a strong, randomly generated value in any shared or production environment.
11+
# Generate one with: openssl rand -base64 32
12+
POSTGRES_PASSWORD=
13+
14+
# POSTGRES_DB defaults to "privateconnect" if not set.
15+
POSTGRES_DB=privateconnect

apps/api/.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ EMAIL_FROM="Private Connect <noreply@yourdomain.com>"
2020
# Public URL for magic links
2121
APP_URL="https://yourdomain.com"
2222

23+
# ============================================
24+
# Field-level encryption key (required for AI API key storage)
25+
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
26+
# Must be exactly 64 hex characters (32 bytes). Keep this secret and back it up.
27+
# ============================================
28+
FIELD_ENCRYPTION_KEY=
29+
2330
# ============================================
2431
# Ask LLM settings (for /ask page)
2532
# ============================================

apps/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "api",
3-
"version": "0.6.4",
3+
"version": "0.6.8",
44
"private": true,
55
"scripts": {
66
"dev": "nest start --watch",
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- Migration: Hash API keys
2+
-- Replaces the plaintext `key` column with a SHA-256 `keyHash` column.
3+
-- The raw key is never stored after this migration.
4+
5+
-- Step 1: Add the new keyHash column (nullable initially so we can backfill)
6+
ALTER TABLE "ApiKey" ADD COLUMN "keyHash" TEXT;
7+
8+
-- Step 2: Backfill keyHash for all existing rows by hashing the plaintext key.
9+
-- encode(digest(key, 'sha256'), 'hex') uses the pgcrypto extension.
10+
-- If pgcrypto is not available, run: CREATE EXTENSION IF NOT EXISTS pgcrypto;
11+
CREATE EXTENSION IF NOT EXISTS pgcrypto;
12+
UPDATE "ApiKey" SET "keyHash" = encode(digest("key", 'sha256'), 'hex');
13+
14+
-- Step 3: Make keyHash NOT NULL and add the unique constraint
15+
ALTER TABLE "ApiKey" ALTER COLUMN "keyHash" SET NOT NULL;
16+
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
17+
18+
-- Step 4: Drop the old plaintext key column
19+
ALTER TABLE "ApiKey" DROP COLUMN "key";

apps/api/prisma/schema.prisma

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ model Workspace {
6262
// AI Copilot settings (workspace-level defaults)
6363
aiProvider String? // ollama | openai | anthropic
6464
aiModel String? // Model name (e.g., llama3, gpt-4o, claude-sonnet)
65-
aiApiKey String? // Encrypted API key for cloud providers
65+
aiApiKey String? // AES-256-GCM encrypted API key for cloud providers (see encryptField/decryptField in security.ts)
6666
aiAutoAnalyze Boolean @default(false) // Auto-analyze errors
6767
aiOllamaUrl String? // Custom Ollama URL (default: http://localhost:11434)
6868
@@ -78,8 +78,8 @@ model ApiKey {
7878
id String @id @default(uuid())
7979
workspaceId String
8080
name String
81-
key String @unique
82-
keyPrefix String // First 8 chars for display (pc_xxxxxxxx...)
81+
keyHash String @unique // SHA-256 hash of the key (never store plaintext)
82+
keyPrefix String // First 11 chars for display (pc_xxxxxxxx...)
8383
allowedIpRanges String[] // CIDR ranges: ["10.0.0.0/8", "192.168.1.0/24"]
8484
createdAt DateTime @default(now())
8585
lastUsedAt DateTime?

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,10 +340,11 @@ export class AgentsService {
340340
}
341341

342342
async validateWorkspaceApiKey(apiKey: string, clientIp?: string) {
343+
const keyHash = createHash('sha256').update(apiKey).digest('hex');
343344
// Use withoutRls() for API key validation - we don't know the workspace yet
344345
const key = await this.prisma.withoutRls(() =>
345346
this.prisma.apiKey.findUnique({
346-
where: { key: apiKey },
347+
where: { keyHash },
347348
include: { workspace: true },
348349
})
349350
);

apps/api/src/ai/ai.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Injectable } from '@nestjs/common';
22
import { PrismaService } from '../prisma/prisma.service';
3-
import { SecureLogger } from '../common/security';
3+
import { SecureLogger, encryptField, decryptField } from '../common/security';
44

55
export interface AIConfig {
66
provider: 'ollama' | 'openai' | 'anthropic';
@@ -66,7 +66,7 @@ export class AIService {
6666
return {
6767
provider: workspace.aiProvider as AIConfig['provider'],
6868
model: workspace.aiModel || this.getDefaultModel(workspace.aiProvider),
69-
apiKey: workspace.aiApiKey || undefined,
69+
apiKey: workspace.aiApiKey ? decryptField(workspace.aiApiKey) : undefined,
7070
ollamaUrl: workspace.aiOllamaUrl || this.defaultOllamaUrl,
7171
};
7272
}
@@ -80,7 +80,7 @@ export class AIService {
8080
data: {
8181
aiProvider: config.provider,
8282
aiModel: config.model,
83-
aiApiKey: config.apiKey,
83+
aiApiKey: config.apiKey != null ? encryptField(config.apiKey) : config.apiKey,
8484
aiOllamaUrl: config.ollamaUrl,
8585
},
8686
});

apps/api/src/api-keys/api-keys.service.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { Injectable, NotFoundException, ForbiddenException, ConflictException } from '@nestjs/common';
22
import { PrismaService } from '../prisma/prisma.service';
3-
import { randomBytes } from 'crypto';
3+
import { randomBytes, createHash } from 'crypto';
44
import { Prisma } from '@prisma/client';
55

6+
function hashApiKey(key: string): string {
7+
return createHash('sha256').update(key).digest('hex');
8+
}
9+
610
@Injectable()
711
export class ApiKeysService {
812
constructor(private prisma: PrismaService) {}
@@ -17,13 +21,14 @@ export class ApiKeysService {
1721
}> {
1822
const key = `pc_${randomBytes(24).toString('hex')}`;
1923
const keyPrefix = key.slice(0, 11); // "pc_" + first 8 chars
24+
const keyHash = hashApiKey(key);
2025

2126
try {
2227
const apiKey = await this.prisma.apiKey.create({
2328
data: {
2429
workspaceId,
2530
name: name.trim(),
26-
key,
31+
keyHash,
2732
keyPrefix,
2833
},
2934
});
@@ -91,10 +96,11 @@ export class ApiKeysService {
9196

9297
// Validate an API key and return the workspace
9398
async validateApiKey(key: string) {
99+
const keyHash = hashApiKey(key);
94100
// Use withoutRls() for API key validation - we don't know the workspace yet
95101
const apiKey = await this.prisma.withoutRls(() =>
96102
this.prisma.apiKey.findUnique({
97-
where: { key },
103+
where: { keyHash },
98104
include: { workspace: true },
99105
})
100106
);

apps/api/src/auth/api-key.guard.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
2+
import { createHash } from 'crypto';
23
import { PrismaService } from '../prisma/prisma.service';
34

5+
function hashApiKey(key: string): string {
6+
return createHash('sha256').update(key).digest('hex');
7+
}
8+
49
/**
510
* Check if an IP address matches a CIDR range
611
* Supports IPv4 only for now
@@ -69,7 +74,7 @@ export class ApiKeyGuard implements CanActivate {
6974
// Use withoutRls() because we don't know the workspace yet - we're authenticating
7075
const key = await this.prisma.withoutRls(() =>
7176
this.prisma.apiKey.findUnique({
72-
where: { key: apiKey },
77+
where: { keyHash: hashApiKey(apiKey) },
7378
include: { workspace: true },
7479
})
7580
);

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
33
import { ThrottlerGuard, Throttle } from '@nestjs/throttler';
44
import { Request, Response } from 'express';
55
import { AuthService } from './auth.service';
6-
import { authRateLimiter } from '../common/rate-limiter';
6+
import { authRateLimiter, authEmailRateLimiter } from '../common/rate-limiter';
77

88
interface RegisterDto {
99
email: string;
@@ -51,6 +51,16 @@ export class AuthController {
5151
if (!body.email || !body.workspaceName) {
5252
throw new UnauthorizedException('Email and workspace name are required');
5353
}
54+
55+
// Rate limit by email address to prevent magic-link spam to a target inbox
56+
const normalizedEmail = body.email.toLowerCase().trim();
57+
if (!authEmailRateLimiter.isAllowed(`register-email:${normalizedEmail}`)) {
58+
throw new HttpException(
59+
{ error: 'Too many requests', message: 'Please wait before trying again.', retryAfter: authEmailRateLimiter.getResetTime(`register-email:${normalizedEmail}`) },
60+
HttpStatus.TOO_MANY_REQUESTS,
61+
);
62+
}
63+
5464
return this.authService.register(body.email, body.workspaceName);
5565
}
5666

@@ -85,6 +95,16 @@ export class AuthController {
8595
if (!body.email) {
8696
throw new UnauthorizedException('Email is required');
8797
}
98+
99+
// Rate limit by email address to prevent magic-link spam to a target inbox
100+
const normalizedEmail = body.email.toLowerCase().trim();
101+
if (!authEmailRateLimiter.isAllowed(`login-email:${normalizedEmail}`)) {
102+
throw new HttpException(
103+
{ error: 'Too many requests', message: 'Please wait before trying again.', retryAfter: authEmailRateLimiter.getResetTime(`login-email:${normalizedEmail}`) },
104+
HttpStatus.TOO_MANY_REQUESTS,
105+
);
106+
}
107+
88108
return this.authService.login(body.email);
89109
}
90110

0 commit comments

Comments
 (0)