DOMAIN:SECURITY:SECURITY_HARDENING¶
OWNER: victoria
ALSO_USED_BY: pol, hugo, piotr, koen, tjitte, arjan
UPDATED: 2026-03-24
SCOPE: all GE deployments — internal and client projects
STACK: Node.js, Next.js, Hono, PostgreSQL, k3s, Docker
CORE_PRINCIPLE¶
RULE: harden every layer — defense in depth, not perimeter-only
RULE: least privilege everywhere — processes, users, network, filesystem
RULE: secure by default — security features ON, explicitly opt out (never opt in)
RULE: reduce attack surface — remove everything not strictly necessary
NODEJS_SECURITY_HARDENING¶
DEPENDENCY_MANAGEMENT¶
RULE: use npm ci (not npm install) in CI/CD — deterministic, uses lockfile only
RULE: commit package-lock.json — non-negotiable
RULE: audit dependencies before adoption — check maintainers, security history, license
RULE: pin exact versions in production (no ^ or ~ ranges for critical deps)
# Before adding any new dependency
TOOL: npm info {package} — check maintainers, publish frequency
TOOL: socket.dev — check for supply chain attack indicators
TOOL: bundlephobia.com — check bundle size impact
CHECK: does this package have > 1 maintainer?
CHECK: last published < 6 months ago?
CHECK: any open security issues?
CHECK: MIT/ISC/Apache-2.0 license?
RUNTIME_SECURITY¶
// RULE: disable eval and dynamic code execution
// In Node.js 20+: use --disallow-code-generation-from-strings flag
// RULE: use strict mode everywhere
// tsconfig.json: "strict": true (enforces this)
// ANTI_PATTERN: using eval(), new Function(), vm.runInNewContext() with user input
// FIX: never use eval-like functions — find alternative approach
// ANTI_PATTERN: child_process.exec with user input (shell injection)
// FIX: use child_process.execFile with argument array
import { execFile } from 'child_process'
execFile('convert', [inputFile, '-resize', '800x600', outputFile], callback)
// NOT: exec(`convert ${inputFile} -resize 800x600 ${outputFile}`)
// RULE: set explicit timeouts on all network operations
// HTTP client: timeout option
// Database: statement_timeout
// External APIs: AbortController with timeout
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
const response = await fetch(url, { signal: controller.signal })
clearTimeout(timeout)
PROCESS_SECURITY¶
# Run Node.js as non-root user
# Dockerfile:
USER node
# Drop capabilities
# In k8s pod spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
capabilities:
drop: ["ALL"]
# Limit memory to prevent DoS
NODE_OPTIONS="--max-old-space-size=512"
# Enable abort-on-uncaught-exception in production
NODE_OPTIONS="--abort-on-uncaught-exception"
ENVIRONMENT_VARIABLES¶
RULE: never log process.env — may contain secrets
RULE: validate all required env vars at startup — fail fast if missing
RULE: never use NEXT_PUBLIC_ prefix for secrets (exposed to browser)
// Startup validation pattern
const REQUIRED_ENV = ['DATABASE_URL', 'VAULT_ADDR', 'VAULT_ROLE_ID', 'VAULT_SECRET_ID'] as const
for (const key of REQUIRED_ENV) {
if (!process.env[key]) {
console.error(`FATAL: missing required env var: ${key}`)
process.exit(1)
}
}
PROTOTYPE_POLLUTION_PREVENTION¶
// ANTI_PATTERN: using Object.assign or spread with untrusted input
const merged = { ...defaults, ...userInput } // userInput can have __proto__
// FIX: validate/sanitize keys, or use Map
// FIX: use Object.create(null) for dictionaries
const safeDict = Object.create(null)
// FIX: freeze prototypes in critical paths
Object.freeze(Object.prototype) // aggressive — test thoroughly
NEXTJS_SECURITY_HEADERS¶
REQUIRED_HEADERS¶
// next.config.ts — MANDATORY for all GE projects
import type { NextConfig } from 'next'
const securityHeaders = [
// HSTS — force HTTPS for 1 year, include subdomains
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains; preload',
},
// Prevent MIME type sniffing
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
// Prevent clickjacking
{
key: 'X-Frame-Options',
value: 'DENY',
},
// Control referrer information
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
// Disable browser features not needed
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), payment=()',
},
// CSP — the most important header
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'nonce-{random}'", // nonce-based for inline scripts
"style-src 'self' 'unsafe-inline'", // needed for CSS-in-JS — tighten with nonce if possible
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"upgrade-insecure-requests",
].join('; '),
},
]
const config: NextConfig = {
poweredBy: false, // remove X-Powered-By header
async headers() {
return [{
source: '/(.*)',
headers: securityHeaders,
}]
},
}
export default config
CSP_NONCE_PATTERN¶
// middleware.ts — generate nonce per request for CSP
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(req: NextRequest) {
const nonce = crypto.randomUUID()
const csp = `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none';`
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', csp)
response.headers.set('x-nonce', nonce) // pass to server components
return response
}
NEXTJS_SPECIFIC_HARDENING¶
CHECK: productionBrowserSourceMaps: false (default, verify not overridden)
CHECK: poweredBy: false in next.config
CHECK: no sensitive data in getStaticProps (cached/CDN-served)
CHECK: API routes validate Content-Type header
CHECK: Server Actions validate CSRF token (built into Next.js 14+)
CHECK: images.domains restricted to expected domains only
CHECK: redirects/rewrites don't expose internal paths
CHECK: error.tsx does NOT render error.message to user (may contain internals)
ANTI_PATTERN: using getStaticProps with user-specific data — served from CDN cache
FIX: use getServerSideProps or server components for user-specific data
ANTI_PATTERN: exposing internal API URLs through Next.js rewrites
FIX: rewrites should not expose internal service names or paths
POSTGRESQL_SECURITY¶
ROW_LEVEL_SECURITY¶
-- RULE: RLS is MANDATORY for all tenant-scoped tables in client projects
-- Enable RLS on table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Force RLS even for table owner (important!)
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
-- Create policy for tenant isolation
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
-- Set tenant context per connection/transaction
SET LOCAL app.tenant_id = '{tenant-uuid}';
-- Verify RLS is active
SELECT tablename, rowsecurity FROM pg_tables
WHERE schemaname = 'public' AND tablename = 'orders';
RLS_RULES¶
RULE: enable RLS on EVERY tenant-scoped table — no exceptions
RULE: FORCE ROW LEVEL SECURITY — prevents bypass by table owner role
RULE: policies must cover ALL operations (SELECT, INSERT, UPDATE, DELETE)
RULE: set tenant context at connection level, not query level
RULE: test RLS with explicit cross-tenant queries — must return empty
ANTI_PATTERN: RLS on SELECT only — INSERT/UPDATE/DELETE bypass isolation
FIX: USING clause (reads) AND WITH CHECK clause (writes) on every policy
ANTI_PATTERN: application role is table owner — bypasses RLS by default
FIX: use FORCE ROW LEVEL SECURITY, or use non-owner role
-- TEST: verify RLS works
SET LOCAL app.tenant_id = 'tenant-a-uuid';
SELECT count(*) FROM orders WHERE tenant_id = 'tenant-b-uuid';
-- MUST return 0. If > 0, RLS is broken → CRITICAL finding.
CONNECTION_SECURITY¶
-- Require SSL for all connections
ALTER SYSTEM SET ssl = on;
-- In pg_hba.conf: require SSL for remote connections
# TYPE DATABASE USER ADDRESS METHOD
hostssl all all 0.0.0.0/0 scram-sha-256
-- Set connection limits per role
ALTER ROLE app_user CONNECTION LIMIT 20;
-- Statement timeout to prevent long-running queries (DoS prevention)
ALTER ROLE app_user SET statement_timeout = '30s';
-- Prevent superuser login from network
# local all postgres peer
# NO hostssl entry for postgres role
MINIMAL_PRIVILEGES¶
-- Application role: ONLY what it needs
CREATE ROLE app_user WITH LOGIN PASSWORD 'from-vault';
-- Grant specific table access (not ALL TABLES)
GRANT SELECT, INSERT, UPDATE ON orders, products, users TO app_user;
GRANT USAGE ON SEQUENCE orders_id_seq, products_id_seq TO app_user;
-- NEVER grant: CREATE, DROP, ALTER, TRUNCATE, REFERENCES to app role
-- NEVER grant: SUPERUSER, CREATEDB, CREATEROLE to app role
-- Migration role: separate from application role
CREATE ROLE migration_user WITH LOGIN PASSWORD 'from-vault';
GRANT CREATE ON SCHEMA public TO migration_user;
-- Revoke after migration completes
ENCRYPTION¶
-- Column-level encryption for highly sensitive PII
-- Use pgcrypto extension
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Encrypt on insert
INSERT INTO users (email_encrypted)
VALUES (pgp_sym_encrypt('user@example.com', current_setting('app.encryption_key')));
-- Decrypt on read
SELECT pgp_sym_decrypt(email_encrypted, current_setting('app.encryption_key'))
FROM users WHERE id = 1;
-- NOTE: encryption key from Vault, set per connection via SET LOCAL
-- NOTE: encrypted columns cannot be indexed — design queries accordingly
K8S_SECURITY¶
NETWORK_POLICIES¶
# DEFAULT: deny all ingress and egress
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: ge-system
spec:
podSelector: {}
policyTypes: ["Ingress", "Egress"]
---
# Allow specific traffic
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: admin-ui-network
namespace: ge-system
spec:
podSelector:
matchLabels:
app: admin-ui
policyTypes: ["Ingress", "Egress"]
ingress:
- from:
- namespaceSelector:
matchLabels:
name: ingress-nginx
ports:
- port: 3000
egress:
- to:
- namespaceSelector:
matchLabels:
name: ge-system
- podSelector:
matchLabels:
app: postgres
ports:
- port: 5432
- to:
- podSelector:
matchLabels:
app: vault
ports:
- port: 8200
- to: # DNS resolution
- namespaceSelector: {}
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
RULE: every namespace MUST have a default-deny NetworkPolicy
RULE: explicitly allow only required traffic per pod
RULE: include DNS egress (port 53) — pods need DNS resolution
POD_SECURITY_STANDARDS¶
# Pod security context — REQUIRED for all GE pods
apiVersion: v1
kind: Pod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
limits:
memory: "512Mi"
cpu: "500m"
requests:
memory: "256Mi"
cpu: "100m"
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir:
sizeLimit: "100Mi"
RULE: runAsNonRoot: true — no root containers
RULE: readOnlyRootFilesystem: true — mount writable dirs explicitly (tmp, data)
RULE: drop ALL capabilities — add back only if absolutely needed
RULE: allowPrivilegeEscalation: false — always
RULE: resource limits on EVERY container — prevent noisy neighbor DoS
RULE: seccompProfile: RuntimeDefault — syscall filtering
ANTI_PATTERN: hostNetwork: true — causes port conflicts on rolling updates (GE learned this)
ANTI_PATTERN: hostPath mounts — escape container isolation
ANTI_PATTERN: privileged: true — full host access
FIX: use none of these unless absolutely necessary, document exception
RBAC¶
# Service account per application (not default)
apiVersion: v1
kind: ServiceAccount
metadata:
name: admin-ui-sa
namespace: ge-system
automountServiceAccountToken: false # disable auto-mount unless needed
---
# Minimal Role
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: admin-ui-role
namespace: ge-system
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list"]
resourceNames: ["admin-ui-config"]
# NO wildcard resources or verbs
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: admin-ui-binding
namespace: ge-system
subjects:
- kind: ServiceAccount
name: admin-ui-sa
roleRef:
kind: Role
name: admin-ui-role
apiGroup: rbac.authorization.k8s.io
RULE: one ServiceAccount per application — never use default SA
RULE: automountServiceAccountToken: false unless pod needs k8s API access
RULE: least privilege RBAC — no ClusterRole unless genuinely cluster-scoped
RULE: no wildcard (*) in resources or verbs
RULE: use resourceNames where possible — restrict to specific objects
SECRETS_IN_K8S¶
RULE: k8s Secrets are base64-encoded, NOT encrypted at rest by default
RULE: enable encryption at rest for etcd (k3s: --kube-apiserver-arg encryption-provider-config)
RULE: prefer External Secrets Operator syncing from Vault
RULE: never expose secrets as environment variables if avoidable (visible in /proc)
# Prefer volume mount over env var for secrets
spec:
containers:
- name: app
volumeMounts:
- name: secrets
mountPath: /run/secrets
readOnly: true
volumes:
- name: secrets
secret:
secretName: app-secrets
defaultMode: 0400 # owner read-only
IMAGE_SECURITY¶
TOOL: trivy image {image}:{tag} — scan before deployment
TOOL: cosign verify — verify image signatures
CHECK: all images from trusted registries (no docker.io random images)
CHECK: image tags are immutable (use digest, not :latest)
CHECK: base images updated regularly (monthly minimum)
CHECK: no secrets baked into image layers (multi-stage build)
DOCKER_IMAGE_SECURITY¶
DISTROLESS_IMAGES¶
# Multi-stage build with distroless final image
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
RUN npm run build
# Final stage: distroless
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./
# Distroless has no shell, no package manager, no utilities
# Minimal attack surface — attacker can't exec into container
USER nonroot:nonroot
EXPOSE 3000
CMD ["server.js"]
DOCKERFILE_RULES¶
RULE: multi-stage builds — build dependencies never ship to production
RULE: use specific base image versions (node:20.11.1-slim NOT node:latest)
RULE: COPY only necessary files — use .dockerignore
RULE: run as non-root user (USER node or USER nonroot)
RULE: no secrets in build args or ENV — use runtime injection
ANTI_PATTERN: running as root in container
FIX: USER node (or USER 1000) in Dockerfile
ANTI_PATTERN: COPY . . without .dockerignore — ships .env, .git, tests
FIX: maintain .dockerignore, COPY only needed directories
ANTI_PATTERN: using alpine for Node.js — musl libc causes subtle bugs
FIX: use debian-slim or distroless for Node.js production images
ANTI_PATTERN: npm install in production image (includes devDependencies)
FIX: npm ci --production in build stage, copy only node_modules
READ_ONLY_FILESYSTEM¶
# k8s: enforce read-only root filesystem
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
- name: next-cache
mountPath: /app/.next/cache # Next.js needs writable cache dir
volumes:
- name: tmp
emptyDir: { sizeLimit: "100Mi" }
- name: next-cache
emptyDir: { sizeLimit: "500Mi" }
RULE: read-only root filesystem with explicit writable mounts
RULE: set sizeLimit on emptyDir — prevent disk exhaustion attacks
BUNNYCDN_WAF_CONFIGURATION¶
GE_USAGE: BunnyCDN with WAF for client-facing applications
WAF_RULES¶
ENABLE:
- SQL injection protection
- XSS protection
- path traversal protection
- protocol attack protection
- remote file inclusion protection
- rate limiting (per IP)
CONFIGURE:
- rate limit: 100 requests/minute per IP (adjust per client)
- block known bad user agents (scanners, known attack tools)
- geo-blocking if client only serves specific regions
- custom rules for API endpoints (stricter rate limits)
MONITOR:
- blocked request logs (daily review)
- rate limit triggers (alert on sustained attacks)
- false positive reports from users (tune rules)
RULE: WAF is defense-in-depth — never rely on WAF alone
RULE: test WAF rules before enabling — avoid blocking legitimate traffic
RULE: log all WAF blocks for incident analysis
HARDENING_VERIFICATION¶
AUTOMATED_CHECKS¶
# Run all hardening checks before deployment
TOOL: kube-bench run --targets node # CIS Kubernetes Benchmark
TOOL: kubescape scan framework nsa # NSA hardening guide
TOOL: trivy fs . --severity CRITICAL,HIGH # dependency vulns
TOOL: trivy image {image} --severity CRITICAL,HIGH # container vulns
TOOL: checkov -d k8s/ # IaC security
TOOL: curl -sI https://target | grep -i "security\|content-security\|strict-transport\|x-frame\|x-content-type\|referrer-policy\|permissions-policy" # header check
HARDENING_CHECKLIST¶
BEFORE_EVERY_DEPLOYMENT:
- [ ] Node.js running as non-root?
- [ ] All security headers present?
- [ ] CSP configured and tested?
- [ ] RLS enabled on all tenant-scoped tables?
- [ ] Database role has minimal privileges?
- [ ] NetworkPolicies applied to namespace?
- [ ] Pod security context enforced?
- [ ] Container image scanned (no CRITICAL vulns)?
- [ ] Secrets in Vault (not env vars or config)?
- [ ] Read-only root filesystem with explicit writable mounts?
- [ ] Resource limits set on all containers?
- [ ] RBAC: no wildcard permissions?
- [ ] WAF configured and tested?
- [ ] Audit logging enabled?
SELF_CHECK¶
BEFORE_SIGNING_OFF_ON_HARDENING:
- [ ] all layers hardened (application, database, container, cluster, network)?
- [ ] least privilege applied at every layer?
- [ ] default deny for network and filesystem access?
- [ ] automated verification passing?
- [ ] no CRITICAL or HIGH findings in scans?
- [ ] hardening documented and reproducible?
READ_ALSO: domains/security/vault-secrets.md, domains/security/owasp-testing.md, domains/security/index.md