Skip to content

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

# .dockerignore — MANDATORY
.git
.env
.env.*
node_modules
*.md
tests/
coverage/
.vscode/
*.log

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