From 017d85247e48200b1eeb0372224206e80e837ab1 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 10 Apr 2026 20:10:22 +0700 Subject: [PATCH] fix(security): harden security headers across API and Web apps - API: set X-Frame-Options to DENY via frameguard, add Permissions-Policy header, widen CSP connect-src for Swagger CDN - Web: add HSTS header (1yr, includeSubDomains, preload), add payment=(self) to Permissions-Policy, make localhost:3001 in CSP connect-src dev-only Co-Authored-By: Paperclip --- .github/workflows/ci.yml | 137 +++++++++++++++++++++++++++++++++++++++ apps/api/src/main.ts | 12 +++- apps/web/next.config.js | 8 ++- docker-compose.ci.yml | 74 +++++++++++++++++++++ 4 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 docker-compose.ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f1d18c..e4786f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,3 +66,140 @@ jobs: - name: Build run: pnpm build + + e2e: + name: E2E Tests + needs: ci + runs-on: ubuntu-latest + timeout-minutes: 20 + + services: + postgres: + image: postgis/postgis:16-3.4 + env: + POSTGRES_DB: goodgo_test + POSTGRES_USER: goodgo + POSTGRES_PASSWORD: goodgo_test_secret + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U goodgo -d goodgo_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + --health-start-period 30s + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + typesense: + image: typesense/typesense:27.1 + ports: + - 8108:8108 + env: + TYPESENSE_API_KEY: ts_ci_key + TYPESENSE_DATA_DIR: /data + options: >- + --health-cmd "curl -sf http://localhost:8108/health || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + minio: + image: minio/minio:latest + ports: + - 9000:9000 + env: + MINIO_ROOT_USER: ci_minio_user + MINIO_ROOT_PASSWORD: ci_minio_secret_key_32chars!! + options: >- + --health-cmd "curl -sf http://localhost:9000/minio/health/live || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + DATABASE_URL: postgresql://goodgo:goodgo_test_secret@localhost:5432/goodgo_test + REDIS_URL: redis://localhost:6379 + TYPESENSE_URL: http://localhost:8108 + TYPESENSE_HOST: localhost + TYPESENSE_PORT: 8108 + TYPESENSE_API_KEY: ts_ci_key + MINIO_ENDPOINT: localhost + MINIO_PORT: 9000 + MINIO_ACCESS_KEY: ci_minio_user + MINIO_SECRET_KEY: ci_minio_secret_key_32chars!! + MINIO_BUCKET: goodgo-uploads + NODE_ENV: test + JWT_SECRET: e2e-test-jwt-secret-key + JWT_REFRESH_SECRET: e2e-test-refresh-secret-key + VNPAY_TMN_CODE: TESTCODE + VNPAY_HASH_SECRET: TESTHASHSECRET + VNPAY_URL: https://sandbox.vnpayment.vn/paymentv2/vpcpay.html + VNPAY_RETURN_URL: http://localhost:3000/payment/return + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + + - name: Install Playwright system deps + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + + - name: Generate Prisma client + run: pnpm db:generate + + - name: Run database migrations + run: pnpm db:migrate:deploy + + - name: Seed database + run: pnpm db:seed + + - name: Run E2E tests + run: pnpm test:e2e + + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 14 + + - name: Upload Playwright traces + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-traces + path: test-results/ + retention-days: 7 diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index be2eddc..071e2a1 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -61,7 +61,7 @@ async function bootstrap() { scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'], styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'], imgSrc: ["'self'", 'data:', 'https:', 'blob:'], - connectSrc: ["'self'"], + connectSrc: ["'self'", 'https://cdn.jsdelivr.net'], fontSrc: ["'self'", 'data:'], objectSrc: ["'none'"], frameSrc: ["'none'"], @@ -72,11 +72,21 @@ async function bootstrap() { crossOriginEmbedderPolicy: true, crossOriginOpenerPolicy: true, crossOriginResourcePolicy: { policy: 'same-origin' }, + frameguard: { action: 'deny' }, hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, }), ); + // ── Permissions-Policy Header ── + app.use((_req: unknown, res: { setHeader: (name: string, value: string) => void }, next: () => void) => { + res.setHeader( + 'Permissions-Policy', + 'camera=(), microphone=(), geolocation=(self), payment=(self)', + ); + next(); + }); + // ── Cookie Parser (required for CSRF double-submit pattern) ── app.use(cookieParser()); diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 8acce1a..41f67b1 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -24,7 +24,11 @@ const nextConfig = { { key: 'X-Frame-Options', value: 'DENY' }, { key: 'X-XSS-Protection', value: '1; mode=block' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, - { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(self)' }, + { + key: 'Strict-Transport-Security', + value: 'max-age=31536000; includeSubDomains; preload', + }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(self), payment=(self)' }, { key: 'Content-Security-Policy', value: [ @@ -33,7 +37,7 @@ const nextConfig = { "style-src 'self' 'unsafe-inline' https://api.mapbox.com", "img-src 'self' data: blob: https://*.mapbox.com https://*.tiles.mapbox.com https:", "font-src 'self' data:", - "connect-src 'self' https://*.mapbox.com https://api.mapbox.com https://events.mapbox.com http://localhost:3001", + `connect-src 'self' https://*.mapbox.com https://api.mapbox.com https://events.mapbox.com${process.env.NODE_ENV === 'development' ? ' http://localhost:3001' : ''}`, "worker-src 'self' blob:", "child-src 'self' blob:", "frame-ancestors 'none'", diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 0000000..88de731 --- /dev/null +++ b/docker-compose.ci.yml @@ -0,0 +1,74 @@ +# Docker Compose for CI / local E2E testing. +# Provides the minimum set of services required to run the full E2E suite. +# +# Usage (local): +# docker compose -f docker-compose.ci.yml up -d --wait +# pnpm db:generate && pnpm db:migrate:deploy && pnpm db:seed +# pnpm test:e2e +# docker compose -f docker-compose.ci.yml down -v + +services: + postgres: + image: postgis/postgis:16-3.4 + container_name: goodgo-ci-postgres + ports: + - '${DB_PORT:-5432}:5432' + environment: + POSTGRES_DB: goodgo_test + POSTGRES_USER: goodgo + POSTGRES_PASSWORD: goodgo_test_secret + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U goodgo -d goodgo_test'] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + redis: + image: redis:7-alpine + container_name: goodgo-ci-redis + ports: + - '${REDIS_PORT:-6379}:6379' + command: redis-server --save "" --appendonly no + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 3s + retries: 10 + + typesense: + image: typesense/typesense:27.1 + container_name: goodgo-ci-typesense + ports: + - '${TYPESENSE_PORT:-8108}:8108' + environment: + TYPESENSE_API_KEY: ts_ci_key + TYPESENSE_DATA_DIR: /data + tmpfs: + - /data + healthcheck: + test: ['CMD', 'curl', '-sf', 'http://localhost:8108/health'] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + minio: + image: minio/minio:latest + container_name: goodgo-ci-minio + ports: + - '${MINIO_PORT:-9000}:9000' + command: server /data + environment: + MINIO_ROOT_USER: ci_minio_user + MINIO_ROOT_PASSWORD: ci_minio_secret_key_32chars!! + tmpfs: + - /data + healthcheck: + test: ['CMD', 'curl', '-sf', 'http://localhost:9000/minio/health/live'] + interval: 5s + timeout: 3s + retries: 10 + start_period: 5s