SECURITY HARDENING PLAN - nself-org/nchat GitHub Wiki
Project: nself-chat (nchat) Version: v0.9.0+ Last Updated: February 3, 2026 Status: Implementation Plan
- Executive Summary
- Current Security Posture
- Rate Limiting (Task 124)
- CSRF Protection (Task 125)
- SSRF Protection (Task 126)
- XSS Protection
- Secrets Audit (Task 127)
- Security Scans (Task 128)
- Additional Hardening
- Implementation Checklist
- Security Testing
- Monitoring & Alerting
This document provides a comprehensive security hardening implementation plan for nself-chat covering Tasks 124-128 from the TODO.md backlog. The plan addresses rate limiting, CSRF protection, SSRF/XSS prevention, secrets management, and security scanning integration.
- Defense in Depth: Multiple layers of security controls
- Zero Trust: Verify every request, assume breach
- Compliance Ready: Meet SOC 2, GDPR, and HIPAA requirements
- Automated Security: CI/CD integrated security scanning
| Metric | Current | Target |
|---|---|---|
| Security Headers Score | A | A+ |
| Dependency Vulnerabilities | 0 critical | 0 critical/high |
| API Rate Limiting Coverage | ~60% | 100% |
| CSRF Protection Coverage | Partial | 100% mutations |
| Security Scan Frequency | Weekly | Every PR |
| Component | Status | Location |
|---|---|---|
| Security Headers | Implemented |
next.config.js, src/middleware.ts
|
| CSP (Content Security Policy) | Implemented | Both static and dynamic nonce-based |
| Rate Limiting | Partial | src/middleware/rate-limit.ts |
| CSRF Protection | Implemented | src/lib/security/csrf.ts |
| Input Validation | Implemented | src/lib/security/input-validation.ts |
| IP Blocking | Implemented | src/lib/security/ip-blocker.ts |
| HTML Sanitization | Implemented | DOMPurify via isomorphic-dompurify
|
| CodeQL Scanning | Implemented | .github/workflows/codeql.yml |
| Dependency Audit | Partial |
pnpm audit in CI |
| SSRF Protection | Partial | /api/unfurl/route.ts |
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'...
X-Frame-Options: SAMEORIGIN (next.config.js) / DENY (middleware)
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-XSS-Protection: 1; mode=block
Rate limiting exists in src/middleware/rate-limit.ts with:
- In-memory sliding window algorithm
- Per-endpoint configurations
- User ID or IP-based tracking
- Penalty box for repeat offenders
Priority: High Effort: 2-3 days
Replace in-memory store with Redis for distributed rate limiting:
// src/lib/security/redis-rate-limiter.ts
import Redis from 'ioredis'
interface RateLimitConfig {
maxRequests: number
windowSeconds: number
burst?: number
keyPrefix: string
}
class RedisRateLimiter {
private redis: Redis
constructor(redisUrl: string) {
this.redis = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
retryDelayOnFailover: 100,
})
}
async check(key: string, config: RateLimitConfig): Promise<RateLimitResult> {
const fullKey = `${config.keyPrefix}:${key}`
const now = Date.now()
const windowStart = now - config.windowSeconds * 1000
// Use Redis pipeline for atomicity
const pipeline = this.redis.pipeline()
// Remove old entries
pipeline.zremrangebyscore(fullKey, 0, windowStart)
// Add current request
pipeline.zadd(fullKey, now, `${now}:${Math.random()}`)
// Count requests in window
pipeline.zcard(fullKey)
// Set expiry
pipeline.expire(fullKey, config.windowSeconds)
const results = await pipeline.exec()
const count = results?.[2]?.[1] as number
const limit = config.maxRequests + (config.burst || 0)
const allowed = count <= limit
return {
allowed,
remaining: Math.max(0, limit - count),
reset: Math.ceil((now + config.windowSeconds * 1000) / 1000),
limit,
retryAfter: allowed ? undefined : config.windowSeconds,
}
}
}Priority: High Effort: 1 day
Expand rate limit configurations:
// src/middleware/rate-limit.ts (additions)
export const ENDPOINT_RATE_LIMITS: Record<string, RateLimitConfig> = {
// Authentication - Strict
'/api/auth/signin': { maxRequests: 5, windowSeconds: 60 },
'/api/auth/signup': { maxRequests: 3, windowSeconds: 3600 },
'/api/auth/2fa/verify': { maxRequests: 5, windowSeconds: 300 },
'/api/auth/change-password': { maxRequests: 3, windowSeconds: 900 },
'/api/auth/verify-password': { maxRequests: 5, windowSeconds: 300 },
// Sensitive Operations
'/api/admin': { maxRequests: 100, windowSeconds: 60 },
'/api/admin/bulk-operations': { maxRequests: 5, windowSeconds: 60 },
'/api/export': { maxRequests: 3, windowSeconds: 3600 },
// Messages - Allow burst
'/api/messages': { maxRequests: 30, windowSeconds: 60, burst: 10 },
'/api/messages/schedule': { maxRequests: 10, windowSeconds: 60 },
// Uploads - Resource intensive
'/api/upload': { maxRequests: 10, windowSeconds: 60 },
'/api/upload/complete': { maxRequests: 10, windowSeconds: 60 },
// Search & AI - Compute intensive
'/api/search': { maxRequests: 30, windowSeconds: 60 },
'/api/translate': { maxRequests: 20, windowSeconds: 60 },
'/api/ai': { maxRequests: 10, windowSeconds: 60 },
// Webhooks - High throughput
'/api/webhook': { maxRequests: 100, windowSeconds: 60 },
'/api/bots': { maxRequests: 60, windowSeconds: 60 },
// URL Unfurl - External requests
'/api/unfurl': { maxRequests: 30, windowSeconds: 60 },
// Health checks - No limit
'/api/health': { maxRequests: 1000, windowSeconds: 60 },
'/api/ready': { maxRequests: 1000, windowSeconds: 60 },
}Priority: Medium Effort: 0.5 days
Ensure all API responses include rate limit headers:
// Standard headers to include
'X-RateLimit-Limit': string // Max requests
'X-RateLimit-Remaining': string // Remaining requests
'X-RateLimit-Reset': string // Unix timestamp of reset
'Retry-After': string // Seconds until retry (on 429)Priority: Medium Effort: 1 day
Implement tiered rate limits based on user role:
const TIER_MULTIPLIERS = {
guest: 0.5, // 50% of base limits
member: 1.0, // Base limits
moderator: 1.5, // 150% of base limits
admin: 2.0, // 200% of base limits
owner: 5.0, // 500% of base limits
}
function getRateLimitForUser(baseConfig: RateLimitConfig, userRole: string): RateLimitConfig {
const multiplier = TIER_MULTIPLIERS[userRole] || 1.0
return {
...baseConfig,
maxRequests: Math.ceil(baseConfig.maxRequests * multiplier),
}
}# Test rate limit with curl
for i in {1..15}; do
curl -s -o /dev/null -w "%{http_code} " http://localhost:3000/api/auth/signin
done
# Expected: 200 200 200 200 200 429 429 429 429 429CSRF protection is implemented in src/lib/security/csrf.ts with:
- Token generation and validation
- Double-submit cookie pattern
- HMAC signing
- Timing-safe comparison
Priority: High Effort: 1 day
Apply CSRF protection to all state-changing API routes:
// src/middleware.ts (add to middleware chain)
import { validateCsrfToken, setCsrfToken } from '@/lib/security/csrf'
export async function middleware(request: NextRequest) {
// ... existing code ...
// CSRF validation for API mutations
if (
request.nextUrl.pathname.startsWith('/api/') &&
['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method)
) {
// Skip CSRF for webhooks (use signature verification instead)
if (request.nextUrl.pathname.startsWith('/api/webhook')) {
return NextResponse.next()
}
// Skip CSRF for public endpoints
const publicEndpoints = ['/api/auth/signin', '/api/auth/signup']
if (publicEndpoints.includes(request.nextUrl.pathname)) {
return NextResponse.next()
}
// Validate CSRF token
if (!validateCsrfToken(request)) {
return NextResponse.json(
{ error: 'Invalid CSRF token', code: 'CSRF_INVALID' },
{ status: 403 }
)
}
}
// ... rest of middleware ...
}Priority: High Effort: 0.5 days
Ensure all session cookies use SameSite=Lax or Strict:
// src/lib/security/csrf.ts
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const, // Prevents CSRF from external sites
path: '/',
maxAge: 24 * 60 * 60, // 24 hours
}
// For auth sessions, use Strict
const AUTH_COOKIE_OPTIONS = {
...COOKIE_OPTIONS,
sameSite: 'strict' as const, // Maximum protection
}Priority: High Effort: 0.5 days
Validate Origin/Referer headers for additional CSRF protection:
// src/lib/security/origin-validation.ts
const ALLOWED_ORIGINS = [
process.env.NEXT_PUBLIC_APP_URL,
'http://localhost:3000',
'https://localhost:3000',
].filter(Boolean)
export function validateOrigin(request: NextRequest): boolean {
const origin = request.headers.get('origin')
const referer = request.headers.get('referer')
// In development, be lenient
if (process.env.NODE_ENV === 'development') {
return true
}
// Check Origin header first
if (origin) {
return ALLOWED_ORIGINS.some((allowed) => origin === allowed || origin.startsWith(allowed + '/'))
}
// Fall back to Referer
if (referer) {
return ALLOWED_ORIGINS.some((allowed) => referer.startsWith(allowed))
}
// No origin info - reject for mutations
return false
}Priority: Medium Effort: 0.5 days
Implement automatic token refresh on the client:
// src/hooks/use-csrf.ts
import { useEffect, useState, useCallback } from 'react'
export function useCsrf() {
const [token, setToken] = useState<string | null>(null)
const [headerName, setHeaderName] = useState('X-CSRF-Token')
const refreshToken = useCallback(async () => {
const response = await fetch('/api/csrf')
const data = await response.json()
setToken(data.csrfToken)
setHeaderName(data.headerName)
}, [])
useEffect(() => {
refreshToken()
// Refresh every 12 hours
const interval = setInterval(refreshToken, 12 * 60 * 60 * 1000)
return () => clearInterval(interval)
}, [refreshToken])
return { token, headerName, refreshToken }
}
// Usage in fetch wrapper
export function createSecureFetch(csrfToken: string) {
return async (url: string, options: RequestInit = {}) => {
return fetch(url, {
...options,
headers: {
...options.headers,
'X-CSRF-Token': csrfToken,
},
})
}
}Basic SSRF protection exists in /api/unfurl/route.ts:
- Private IP blocking
- Domain blocklist
- Timeout enforcement
Priority: High Effort: 1 day
Create a comprehensive URL validation service:
// src/lib/security/ssrf-protection.ts
import { URL } from 'url'
import dns from 'dns'
import { promisify } from 'util'
const resolve4 = promisify(dns.resolve4)
const resolve6 = promisify(dns.resolve6)
interface SsrfConfig {
allowedProtocols: string[]
blockedDomains: string[]
allowedDomains?: string[] // Allowlist mode (if set, only these domains allowed)
allowPrivateIPs: boolean
allowLocalhost: boolean
timeoutMs: number
maxRedirects: number
}
const DEFAULT_CONFIG: SsrfConfig = {
allowedProtocols: ['http:', 'https:'],
blockedDomains: [
'metadata.google.internal',
'169.254.169.254', // AWS metadata
'metadata.azure.internal',
],
allowPrivateIPs: false,
allowLocalhost: false,
timeoutMs: 10000,
maxRedirects: 5,
}
export class SsrfProtection {
private config: SsrfConfig
constructor(config: Partial<SsrfConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config }
}
async validateUrl(url: string): Promise<{ valid: boolean; reason?: string }> {
try {
const parsed = new URL(url)
// 1. Protocol check
if (!this.config.allowedProtocols.includes(parsed.protocol)) {
return { valid: false, reason: `Protocol ${parsed.protocol} not allowed` }
}
// 2. Localhost check
if (!this.config.allowLocalhost) {
if (this.isLocalhost(parsed.hostname)) {
return { valid: false, reason: 'Localhost URLs not allowed' }
}
}
// 3. Blocklist check
if (this.isBlockedDomain(parsed.hostname)) {
return { valid: false, reason: 'Domain is blocked' }
}
// 4. Allowlist check (if configured)
if (this.config.allowedDomains && !this.isAllowedDomain(parsed.hostname)) {
return { valid: false, reason: 'Domain not in allowlist' }
}
// 5. DNS resolution check (prevent DNS rebinding)
const ips = await this.resolveHostname(parsed.hostname)
for (const ip of ips) {
if (!this.config.allowPrivateIPs && this.isPrivateIP(ip)) {
return { valid: false, reason: `Resolved to private IP: ${ip}` }
}
}
return { valid: true }
} catch (error) {
return { valid: false, reason: `Invalid URL: ${error.message}` }
}
}
private isLocalhost(hostname: string): boolean {
const localhostPatterns = ['localhost', '127.0.0.1', '::1', '0.0.0.0']
return localhostPatterns.some((p) => hostname.toLowerCase() === p)
}
private isPrivateIP(ip: string): boolean {
// IPv4 private ranges
const ipv4Private = [
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
/^192\.168\./,
/^127\./,
/^169\.254\./, // Link-local
/^0\./,
]
// IPv6 private ranges
const ipv6Private = [/^::1$/, /^fc00:/i, /^fd00:/i, /^fe80:/i]
return ipv4Private.some((p) => p.test(ip)) || ipv6Private.some((p) => p.test(ip))
}
private isBlockedDomain(hostname: string): boolean {
return this.config.blockedDomains.some(
(blocked) =>
hostname.toLowerCase() === blocked || hostname.toLowerCase().endsWith(`.${blocked}`)
)
}
private isAllowedDomain(hostname: string): boolean {
if (!this.config.allowedDomains) return true
return this.config.allowedDomains.some(
(allowed) =>
hostname.toLowerCase() === allowed || hostname.toLowerCase().endsWith(`.${allowed}`)
)
}
private async resolveHostname(hostname: string): Promise<string[]> {
const results: string[] = []
try {
const ipv4 = await resolve4(hostname)
results.push(...ipv4)
} catch {
// Ignore resolution errors
}
try {
const ipv6 = await resolve6(hostname)
results.push(...ipv6)
} catch {
// Ignore resolution errors
}
return results
}
}
// Singleton instance
export const ssrfProtection = new SsrfProtection()Priority: High Effort: 0.5 days
Create a secure fetch function for external requests:
// src/lib/security/secure-fetch.ts
import { ssrfProtection } from './ssrf-protection'
interface SecureFetchOptions extends RequestInit {
maxRedirects?: number
validateRedirects?: boolean
}
export async function secureFetch(
url: string,
options: SecureFetchOptions = {}
): Promise<Response> {
const { maxRedirects = 5, validateRedirects = true, ...fetchOptions } = options
// Validate initial URL
const validation = await ssrfProtection.validateUrl(url)
if (!validation.valid) {
throw new Error(`SSRF blocked: ${validation.reason}`)
}
// Set timeout
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000)
try {
// Disable automatic redirects to validate each hop
const response = await fetch(url, {
...fetchOptions,
redirect: validateRedirects ? 'manual' : 'follow',
signal: controller.signal,
})
// Handle redirects manually
if (validateRedirects && [301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location')
if (!location) {
throw new Error('Redirect without location header')
}
if (maxRedirects <= 0) {
throw new Error('Too many redirects')
}
// Resolve relative URLs
const redirectUrl = new URL(location, url).toString()
// Validate redirect URL
const redirectValidation = await ssrfProtection.validateUrl(redirectUrl)
if (!redirectValidation.valid) {
throw new Error(`SSRF blocked redirect: ${redirectValidation.reason}`)
}
// Follow redirect
return secureFetch(redirectUrl, {
...options,
maxRedirects: maxRedirects - 1,
})
}
return response
} finally {
clearTimeout(timeoutId)
}
}Priority: High Effort: 1 day
Implement DNS rebinding prevention:
// src/lib/security/dns-cache.ts
interface CachedDnsEntry {
ips: string[]
resolvedAt: number
ttl: number
}
class DnsCache {
private cache = new Map<string, CachedDnsEntry>()
private readonly DEFAULT_TTL = 300000 // 5 minutes
async resolve(hostname: string): Promise<string[]> {
const cached = this.cache.get(hostname)
const now = Date.now()
// Return cached if still valid
if (cached && now - cached.resolvedAt < cached.ttl) {
return cached.ips
}
// Resolve fresh
const ips = await this.performDnsResolution(hostname)
// Cache the result
this.cache.set(hostname, {
ips,
resolvedAt: now,
ttl: this.DEFAULT_TTL,
})
return ips
}
// Pin DNS resolution during request lifetime
async validateAndPin(url: string): Promise<string> {
const parsed = new URL(url)
const ips = await this.resolve(parsed.hostname)
if (ips.length === 0) {
throw new Error('Could not resolve hostname')
}
// Validate all resolved IPs
for (const ip of ips) {
if (isPrivateIP(ip)) {
throw new Error(`DNS resolution returned private IP: ${ip}`)
}
}
// Return URL with first IP (pinned)
// This prevents DNS rebinding during the request
const pinnedUrl = new URL(url)
pinnedUrl.hostname = ips[0]
return pinnedUrl.toString()
}
}
export const dnsCache = new DnsCache()XSS protection is implemented via:
- DOMPurify (
isomorphic-dompurify) for HTML sanitization - CSP headers (with nonce support)
- Input validation schemas
Priority: High Effort: 1 day
Strengthen CSP with stricter directives:
// src/middleware.ts
function getStrictCSP(nonce: string, isDev: boolean): string {
const directives = [
// Default: self only
`default-src 'self'`,
// Scripts: nonce-based, no inline
`script-src 'self' 'nonce-${nonce}' ${isDev ? "'unsafe-eval'" : ''}`,
// Styles: self + inline (Tailwind needs this)
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
// Fonts
`font-src 'self' https://fonts.gstatic.com data:`,
// Images: allow https + data for avatars/embeds
`img-src 'self' data: blob: https:`,
// Media
`media-src 'self' blob: data:`,
// Connections: API endpoints + WebSockets
`connect-src 'self' ${getConnectSources(isDev)}`,
// Frames: self only (no external embeds by default)
`frame-src 'self' https://www.youtube.com https://player.vimeo.com`,
// Objects: none (no Flash/Java)
`object-src 'none'`,
// Base: self only
`base-uri 'self'`,
// Forms: self only
`form-action 'self'`,
// Frame ancestors: none (prevent clickjacking)
`frame-ancestors 'none'`,
// Block mixed content
`block-all-mixed-content`,
// Upgrade HTTP to HTTPS
`upgrade-insecure-requests`,
// Report violations
`report-uri /api/csp-report`,
`report-to csp-endpoint`,
]
return directives.join('; ')
}
function getConnectSources(isDev: boolean): string {
const sources = [
process.env.NEXT_PUBLIC_GRAPHQL_URL,
process.env.NEXT_PUBLIC_AUTH_URL,
process.env.NEXT_PUBLIC_STORAGE_URL,
process.env.NEXT_PUBLIC_SOCKET_URL,
].filter(Boolean)
if (isDev) {
sources.push('ws://localhost:*', 'http://localhost:*')
}
return sources.join(' ')
}Priority: Medium Effort: 0.5 days
Implement CSP violation endpoint:
// src/app/api/csp-report/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { captureError } from '@/lib/sentry-utils'
export async function POST(request: NextRequest) {
try {
const report = await request.json()
// Log to monitoring
console.warn('CSP Violation:', JSON.stringify(report, null, 2))
// Send to Sentry for tracking
captureError(new Error('CSP Violation'), {
tags: { type: 'csp-violation' },
extra: report,
})
return NextResponse.json({ received: true })
} catch {
return NextResponse.json({ error: 'Invalid report' }, { status: 400 })
}
}Priority: High Effort: 1 day
Create a robust HTML sanitization service for TipTap content:
// src/lib/security/html-sanitizer.ts
import DOMPurify from 'isomorphic-dompurify'
// Strict config for user messages
const MESSAGE_CONFIG = {
ALLOWED_TAGS: [
'p',
'br',
'strong',
'b',
'em',
'i',
'u',
's',
'strike',
'a',
'ul',
'ol',
'li',
'blockquote',
'code',
'pre',
'h1',
'h2',
'h3',
'span',
],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'data-mention-id'],
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
ADD_ATTR: ['target'],
FORBID_TAGS: ['script', 'style', 'iframe', 'form', 'input', 'object', 'embed'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus'],
}
// Relaxed config for admin content
const ADMIN_CONFIG = {
...MESSAGE_CONFIG,
ALLOWED_TAGS: [...MESSAGE_CONFIG.ALLOWED_TAGS, 'img', 'video', 'audio'],
ALLOWED_ATTR: [...MESSAGE_CONFIG.ALLOWED_ATTR, 'src', 'alt', 'width', 'height'],
}
export function sanitizeMessage(html: string): string {
// Pre-process: normalize line breaks
const normalized = html.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
// Sanitize
const sanitized = DOMPurify.sanitize(normalized, MESSAGE_CONFIG)
// Post-process: ensure external links open in new tab
return sanitized.replace(
/<a\s+href="(https?:\/\/[^"]+)"/g,
'<a href="$1" target="_blank" rel="noopener noreferrer"'
)
}
export function sanitizeAdminContent(html: string): string {
return DOMPurify.sanitize(html, ADMIN_CONFIG)
}
// Escape for text-only display
export function escapeHtml(text: string): string {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// Remove all HTML tags
export function stripHtml(html: string): string {
return DOMPurify.sanitize(html, { ALLOWED_TAGS: [] })
}Priority: Medium Effort: 0.5 days
Secure markdown rendering:
// src/lib/security/markdown-sanitizer.ts
import { marked } from 'marked'
import DOMPurify from 'isomorphic-dompurify'
// Configure marked for security
marked.setOptions({
headerIds: false,
mangle: false,
sanitize: false, // We use DOMPurify instead
})
export function renderMarkdown(markdown: string): string {
// Convert markdown to HTML
const html = marked.parse(markdown)
// Sanitize the output
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'p',
'br',
'strong',
'b',
'em',
'i',
'u',
's',
'a',
'ul',
'ol',
'li',
'blockquote',
'code',
'pre',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'hr',
'img',
],
ALLOWED_ATTR: ['href', 'target', 'rel', 'src', 'alt', 'class'],
ALLOW_DATA_ATTR: false,
})
}Priority: Critical Effort: 1 day
Add pre-commit secrets scanning:
# Install detect-secrets
pip install detect-secrets
# Generate baseline
detect-secrets scan > .secrets.baseline
# Add to pre-commit hooks
# .pre-commit-config.yaml# .pre-commit-config.yaml
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
exclude: |
(?x)^(
.*\.test\.ts|
.*\.spec\.ts|
\.secrets\.baseline
)$Priority: High Effort: 1 day
Enhance production environment validation:
// scripts/validate-secrets.ts
import { z } from 'zod'
const ProductionSecretsSchema = z.object({
// Database
DATABASE_URL: z.string().url().min(20),
// Auth
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
HASURA_ADMIN_SECRET: z.string().min(32),
CSRF_SECRET: z.string().min(32),
// Services
REDIS_URL: z.string().url().optional(),
MEILISEARCH_MASTER_KEY: z.string().min(16).optional(),
// OAuth (if enabled)
GOOGLE_CLIENT_SECRET: z.string().min(10).optional(),
GITHUB_CLIENT_SECRET: z.string().min(10).optional(),
// Encryption
SOCIAL_MEDIA_ENCRYPTION_KEY: z.string().min(32).optional(),
})
// Validation for no secrets in client bundle
const ClientBundleCheck = z
.object({
NEXT_PUBLIC_GRAPHQL_URL: z.string().url(),
NEXT_PUBLIC_AUTH_URL: z.string().url(),
})
.strict()
export function validateProductionSecrets(): void {
const env = process.env
// Check that no sensitive vars have NEXT_PUBLIC_ prefix
const sensitivePatterns = ['SECRET', 'PASSWORD', 'PRIVATE', 'KEY', 'TOKEN', 'CREDENTIAL']
for (const key of Object.keys(env)) {
if (key.startsWith('NEXT_PUBLIC_')) {
for (const pattern of sensitivePatterns) {
if (key.includes(pattern)) {
throw new Error(
`CRITICAL: Sensitive variable ${key} has NEXT_PUBLIC_ prefix. ` +
`This will expose the value to clients!`
)
}
}
}
}
// Validate required secrets exist
const result = ProductionSecretsSchema.safeParse(env)
if (!result.success) {
console.error('Production secrets validation failed:')
for (const error of result.error.errors) {
console.error(` - ${error.path.join('.')}: ${error.message}`)
}
process.exit(1)
}
console.log('All production secrets validated successfully')
}Priority: Medium Effort: 1 day
Document and implement secret rotation:
# Secret Rotation Runbook
## JWT_SECRET Rotation
1. Generate new secret:
```bash
openssl rand -base64 64
```-
Update environment variable in all environments
-
Deploy with grace period (both secrets valid for 24h)
-
Remove old secret after grace period
- Generate new secret
- Update Hasura environment variable
- Update application environment variable
- Restart Hasura and application
- Verify GraphQL connectivity
- Generate new secret in provider dashboard
- Update environment variable
- Deploy
- Test OAuth flow
- Revoke old secret in provider dashboard
#### 5.4 Git History Cleanup
**Priority**: Low
**Effort**: 0.5 days
Script to check for secrets in git history:
```bash
#!/bin/bash
# scripts/check-git-secrets.sh
echo "Checking git history for potential secrets..."
# Patterns to search for
PATTERNS=(
"password\s*[=:]\s*['\"][^'\"]+['\"]"
"secret\s*[=:]\s*['\"][^'\"]+['\"]"
"api_key\s*[=:]\s*['\"][^'\"]+['\"]"
"private_key"
"-----BEGIN.*PRIVATE KEY-----"
"sk-[a-zA-Z0-9]{20,}"
"ghp_[a-zA-Z0-9]{36}"
)
for pattern in "${PATTERNS[@]}"; do
echo "Searching for: $pattern"
git log -p --all -S "$pattern" --source --all 2>/dev/null | head -20
done
echo "Done. Review output for any leaked secrets."
Priority: High Effort: 1 day
Add comprehensive dependency scanning to CI:
# .github/workflows/security-scan.yml
name: Security Scan
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
schedule:
- cron: '0 6 * * *' # Daily at 6 AM
jobs:
dependency-audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
# pnpm audit
- name: Run pnpm audit
run: pnpm audit --audit-level=moderate
continue-on-error: true
# npm audit for comparison
- name: Run npm audit
run: npm audit --audit-level=moderate
continue-on-error: true
snyk-scan:
name: Snyk Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Snyk to check for vulnerabilities
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
trivy-scan:
name: Trivy Filesystem Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
ignore-unfixed: true
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
semgrep:
name: Semgrep SAST
runs-on: ubuntu-latest
container:
image: returntocorp/semgrep
steps:
- uses: actions/checkout@v4
- name: Run Semgrep
run: semgrep ci --config auto --sarif --output semgrep-results.sarif
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
- name: Upload Semgrep results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: semgrep-results.sarifPriority: High Effort: 0.5 days
Add container image scanning:
# .github/workflows/container-scan.yml
name: Container Security
on:
push:
branches: [main]
paths:
- 'Dockerfile'
- '.github/workflows/container-scan.yml'
jobs:
container-scan:
name: Scan Container Image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t nself-chat:scan .
- name: Run Trivy container scan
uses: aquasecurity/trivy-action@master
with:
image-ref: 'nself-chat:scan'
format: 'sarif'
output: 'trivy-container.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy container results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-container.sarif'
- name: Run Dockle lint
uses: erzz/dockle-action@v1
with:
image: nself-chat:scan
failure-threshold: highPriority: Medium Effort: 2 days
Implement OWASP ZAP scanning:
# .github/workflows/dast.yml
name: DAST Scan
on:
workflow_dispatch:
schedule:
- cron: '0 2 * * 0' # Weekly on Sunday
jobs:
zap-scan:
name: OWASP ZAP Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build application
run: pnpm build
env:
NEXT_PUBLIC_USE_DEV_AUTH: 'true'
- name: Start application
run: |
pnpm start &
sleep 10
- name: OWASP ZAP Baseline Scan
uses: zaproxy/[email protected]
with:
target: 'http://localhost:3000'
rules_file_name: '.zap-rules.tsv'
- name: Upload ZAP report
uses: actions/upload-artifact@v4
with:
name: zap-report
path: report_html.htmlZAP rules configuration:
# .zap-rules.tsv
# Rule ID Alert threshold Ignore
10015 IGNORE # Incomplete or No Cache-control Header Set
10020 WARN # X-Frame-Options Header
10021 FAIL # X-Content-Type-Options Header Missing
10038 WARN # Content Security Policy Header Not Set
10054 WARN # Cookie without SameSite Attribute
10055 FAIL # CSP: Wildcard Directive
90022 IGNORE # Application Error Disclosure (dev mode)Priority: Low Effort: 0.5 days
Add license checking:
# .github/workflows/license-check.yml
name: License Compliance
on:
pull_request:
paths:
- 'package.json'
- 'pnpm-lock.yaml'
jobs:
license-check:
name: Check Licenses
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check licenses
run: npx license-checker --production --onlyAllow "MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;CC0-1.0;CC-BY-3.0;CC-BY-4.0;0BSD;Unlicense"Priority: High Effort: 0.5 days
Add additional security headers:
// Additional headers to add to middleware.ts
const additionalHeaders = {
// Prevent caching of sensitive pages
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
Pragma: 'no-cache',
Expires: '0',
// Cross-Origin policies
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Resource-Policy': 'same-origin',
// Feature policy (more specific than Permissions-Policy)
'Feature-Policy': "camera 'none'; microphone 'none'; geolocation 'none'",
// Expect-CT for Certificate Transparency
'Expect-CT': 'max-age=86400, enforce',
}Priority: High Effort: 1 day
Enhance JWT handling:
// src/lib/security/jwt.ts
import { SignJWT, jwtVerify, JWTPayload } from 'jose'
const JWT_CONFIG = {
algorithm: 'HS256' as const,
issuer: 'nchat',
audience: 'nchat-users',
expiresIn: '1h',
notBefore: '0s',
}
interface TokenPayload extends JWTPayload {
userId: string
role: string
tenantId?: string
}
export async function createToken(payload: TokenPayload): Promise<string> {
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: JWT_CONFIG.algorithm })
.setIssuedAt()
.setIssuer(JWT_CONFIG.issuer)
.setAudience(JWT_CONFIG.audience)
.setExpirationTime(JWT_CONFIG.expiresIn)
.setNotBefore(JWT_CONFIG.notBefore)
.sign(secret)
}
export async function verifyToken(token: string): Promise<TokenPayload | null> {
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
const { payload } = await jwtVerify(token, secret, {
issuer: JWT_CONFIG.issuer,
audience: JWT_CONFIG.audience,
})
return payload as TokenPayload
} catch {
return null
}
}Priority: Medium Effort: 0.5 days
Implement comprehensive password policies:
// src/lib/security/password-policy.ts
import { z } from 'zod'
export const passwordPolicy = z
.string()
.min(12, 'Password must be at least 12 characters')
.max(128, 'Password must be at most 128 characters')
.refine((val) => /[A-Z]/.test(val), 'Password must contain at least one uppercase letter')
.refine((val) => /[a-z]/.test(val), 'Password must contain at least one lowercase letter')
.refine((val) => /[0-9]/.test(val), 'Password must contain at least one number')
.refine((val) => /[^a-zA-Z0-9]/.test(val), 'Password must contain at least one special character')
.refine((val) => !commonPasswords.includes(val.toLowerCase()), 'This password is too common')
.refine((val) => !containsRepeatingChars(val), 'Password cannot contain 4+ repeating characters')
// Top 1000 common passwords (abbreviated)
const commonPasswords = [
'password',
'123456',
'qwerty',
'admin',
'letmein',
// ... load from file
]
function containsRepeatingChars(str: string): boolean {
return /(.)\1{3,}/.test(str)
}
export function checkPasswordStrength(password: string): {
score: number // 0-100
feedback: string[]
} {
let score = 0
const feedback: string[] = []
// Length scoring
if (password.length >= 8) score += 10
if (password.length >= 12) score += 10
if (password.length >= 16) score += 10
// Complexity scoring
if (/[a-z]/.test(password)) score += 10
if (/[A-Z]/.test(password)) score += 10
if (/[0-9]/.test(password)) score += 10
if (/[^a-zA-Z0-9]/.test(password)) score += 15
// Variety scoring
const uniqueChars = new Set(password).size
if (uniqueChars >= password.length * 0.6) score += 15
// Entropy approximation
if (password.length >= 16 && uniqueChars >= 10) score += 10
// Feedback
if (score < 40) feedback.push('Consider a longer password')
if (!/[^a-zA-Z0-9]/.test(password)) feedback.push('Add special characters')
if (uniqueChars < password.length * 0.5) feedback.push('Use more varied characters')
return { score: Math.min(100, score), feedback }
}Priority: High Effort: 1 day
Enhance session security:
// src/lib/security/session-manager.ts
interface SessionConfig {
maxConcurrentSessions: number
sessionTimeout: number // milliseconds
refreshThreshold: number // refresh if less than this time remaining
absoluteTimeout: number // max session lifetime
}
const DEFAULT_CONFIG: SessionConfig = {
maxConcurrentSessions: 5,
sessionTimeout: 60 * 60 * 1000, // 1 hour
refreshThreshold: 15 * 60 * 1000, // 15 minutes
absoluteTimeout: 7 * 24 * 60 * 60 * 1000, // 7 days
}
export class SessionManager {
// Invalidate all sessions for user (logout everywhere)
async invalidateAllSessions(userId: string): Promise<void> {
// Implementation with Redis
}
// Invalidate specific session
async invalidateSession(sessionId: string): Promise<void> {
// Implementation
}
// Check if session should be refreshed
shouldRefresh(session: Session): boolean {
const timeRemaining = session.expiresAt - Date.now()
return timeRemaining < DEFAULT_CONFIG.refreshThreshold
}
// Enforce concurrent session limit
async enforceSessionLimit(userId: string): Promise<void> {
const sessions = await this.getActiveSessions(userId)
if (sessions.length >= DEFAULT_CONFIG.maxConcurrentSessions) {
// Invalidate oldest session
const oldest = sessions.sort((a, b) => a.createdAt - b.createdAt)[0]
await this.invalidateSession(oldest.id)
}
}
}-
Rate Limiting
- Implement Redis-based rate limiter
- Apply rate limits to all API endpoints
- Add rate limit headers to responses
- Test rate limit bypass scenarios
-
CSRF Protection
- Verify CSRF middleware covers all mutations
- Implement origin validation
- Update client to include CSRF tokens
-
SSRF Protection
- Implement centralized URL validation
- Add DNS rebinding protection
- Update
/api/unfurlto use secure fetch
-
XSS Protection
- Audit all HTML rendering points
- Implement strict CSP with nonces
- Add CSP violation reporting
- Test with XSS payloads
-
Secrets Audit
- Run detect-secrets scan
- Validate all environment variables
- Document secret rotation procedures
- Check git history for leaked secrets
-
CI Integration
- Add Snyk to CI pipeline
- Add Semgrep SAST scanning
- Add container scanning
- Configure DAST with OWASP ZAP
-
Monitoring
- Set up security event alerting
- Configure Sentry for security events
- Create security dashboard
-
Additional Headers
- Add Cross-Origin policies
- Configure Expect-CT
- Review and tighten CSP
-
Authentication
- Implement enhanced password policies
- Add session management improvements
- Review JWT configuration
## Authentication
- [ ] Brute force protection
- [ ] Account enumeration
- [ ] Password reset flow
- [ ] Session fixation
- [ ] Token security
## Authorization
- [ ] Horizontal privilege escalation
- [ ] Vertical privilege escalation
- [ ] IDOR (Insecure Direct Object Reference)
- [ ] Missing function-level access control
## Input Validation
- [ ] SQL injection
- [ ] XSS (reflected, stored, DOM)
- [ ] Command injection
- [ ] Path traversal
- [ ] XML/JSON injection
## API Security
- [ ] Rate limiting bypass
- [ ] CSRF bypass
- [ ] CORS misconfiguration
- [ ] Mass assignment
- [ ] Excessive data exposure
## Business Logic
- [ ] Race conditions
- [ ] Price manipulation
- [ ] Workflow bypass// e2e/security/security.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Security Headers', () => {
test('should have secure headers', async ({ page }) => {
const response = await page.goto('/')
expect(response?.headers()['x-frame-options']).toBe('DENY')
expect(response?.headers()['x-content-type-options']).toBe('nosniff')
expect(response?.headers()['strict-transport-security']).toContain('max-age=')
expect(response?.headers()['content-security-policy']).toBeTruthy()
})
})
test.describe('Authentication', () => {
test('should rate limit login attempts', async ({ request }) => {
// Make 10 failed login attempts
for (let i = 0; i < 10; i++) {
await request.post('/api/auth/signin', {
data: { email: '[email protected]', password: 'wrong' },
})
}
// 11th attempt should be rate limited
const response = await request.post('/api/auth/signin', {
data: { email: '[email protected]', password: 'wrong' },
})
expect(response.status()).toBe(429)
})
})
test.describe('CSRF Protection', () => {
test('should reject POST without CSRF token', async ({ request }) => {
const response = await request.post('/api/messages', {
data: { content: 'test' },
})
expect(response.status()).toBe(403)
})
})| Event | Severity | Action |
|---|---|---|
| Multiple failed logins | Medium | Alert after 5 attempts |
| Rate limit exceeded | Low | Log, block after repeat |
| CSRF validation failure | High | Alert immediately |
| XSS attempt detected | High | Alert, block IP |
| SQL injection attempt | Critical | Alert, block IP |
| Admin access from new IP | Medium | Alert for verification |
| Password change | Low | Notify user |
| MFA disabled | High | Alert, require confirmation |
// src/lib/security/security-monitoring.ts
import { captureError, addSentryBreadcrumb } from '@/lib/sentry-utils'
export function logSecurityEvent(event: {
type: 'auth_failure' | 'rate_limit' | 'csrf_failure' | 'xss_attempt' | 'ssrf_blocked'
severity: 'low' | 'medium' | 'high' | 'critical'
userId?: string
ip: string
details: Record<string, unknown>
}): void {
// Add breadcrumb for context
addSentryBreadcrumb('security', event.type, {
severity: event.severity,
ip: event.ip,
...event.details,
})
// Capture as error for high/critical
if (['high', 'critical'].includes(event.severity)) {
captureError(new Error(`Security Event: ${event.type}`), {
tags: {
security_event: event.type,
severity: event.severity,
},
extra: {
userId: event.userId,
ip: event.ip,
...event.details,
},
})
}
// Log to console for all events
console.warn(`[SECURITY] ${event.type}:`, {
severity: event.severity,
ip: event.ip,
...event.details,
})
}// Metrics to track
const securityMetrics = {
// Authentication
'auth.login.success': Counter,
'auth.login.failure': Counter,
'auth.login.rate_limited': Counter,
'auth.password.changed': Counter,
'auth.mfa.enabled': Counter,
'auth.mfa.disabled': Counter,
// Authorization
'authz.denied': Counter,
'authz.role.escalation.attempt': Counter,
// Input validation
'input.xss.blocked': Counter,
'input.sqli.blocked': Counter,
'input.ssrf.blocked': Counter,
// Rate limiting
'ratelimit.exceeded': Counter,
'ratelimit.ip.blocked': Counter,
// CSRF
'csrf.validation.failure': Counter,
}# Required for production
JWT_SECRET=<min 32 chars>
HASURA_ADMIN_SECRET=<min 32 chars>
CSRF_SECRET=<min 32 chars>
DATABASE_URL=<postgresql connection string>
# Optional security features
REDIS_URL=<redis connection string> # For distributed rate limiting
SENTRY_DSN=<sentry dsn> # For security monitoring| Package | Purpose | Version |
|---|---|---|
isomorphic-dompurify |
HTML sanitization | ^2.35.0 |
zod |
Input validation | ^3.24.1 |
jose |
JWT handling | ^5.2.0 |
ioredis |
Redis client | ^5.4.1 |
bcryptjs |
Password hashing | ^3.0.2 |
| Control | SOC 2 | GDPR | Implementation |
|---|---|---|---|
| Access Control | CC6.1 | Art. 32 | RBAC, Session management |
| Encryption | CC6.7 | Art. 32 | TLS, Encryption at rest |
| Logging | CC7.2 | Art. 30 | Audit logs, Sentry |
| Data Retention | CC6.5 | Art. 17 | Configurable retention |
| Breach Notification | CC7.4 | Art. 33 | Security monitoring |
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | 2026-02-03 | Claude | Initial security hardening plan |