From a19222ca30fb2471b198201e7ae125815b4d0036 Mon Sep 17 00:00:00 2001 From: PrimeFactor-Dev Date: Wed, 21 Oct 2026 07:19:47 +0100 Subject: [PATCH 1/4] fix(security): restrict CSP connect-src to known API and RPC origins Expand connect-src in frontend/nginx.conf to allow only the Stellar testnet/mainnet RPC and Horizon endpoints alongside same-origin, preventing data exfiltration by compromised third-party code. Document the policy in SECURITY_XSS_PREVENTION.md. Closes #1488 --- docs/SECURITY_XSS_PREVENTION.md | 22 ++++++++++++++++++++++ frontend/nginx.conf | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/SECURITY_XSS_PREVENTION.md b/docs/SECURITY_XSS_PREVENTION.md index f3d20c06..5280b3be 100644 --- a/docs/SECURITY_XSS_PREVENTION.md +++ b/docs/SECURITY_XSS_PREVENTION.md @@ -174,6 +174,28 @@ CSP with inline script nonces (issue #40 referenced) is a separate concern: - Recommended for future hardening - Would work alongside these sanitization measures +### 7. CSP `connect-src` Restriction (#1488) + +The frontend Nginx config (`frontend/nginx.conf`) now restricts `connect-src` to known API and +RPC origins to prevent data exfiltration by compromised third-party code: + +``` +connect-src 'self' https://soroban-testnet.stellar.org https://horizon-testnet.stellar.org https://soroban-mainnet.stellar.org https://horizon.stellar.org +``` + +**Allowed origins:** + +| Origin | Purpose | +|--------|---------| +| `'self'` | Backend API (same-origin) | +| `https://soroban-testnet.stellar.org` | Soroban RPC (testnet) | +| `https://horizon-testnet.stellar.org` | Horizon API (testnet) | +| `https://soroban-mainnet.stellar.org` | Soroban RPC (mainnet) | +| `https://horizon.stellar.org` | Horizon API (mainnet) | + +**Wallet extensions** (Freighter, Rabet, xBull) communicate via browser extension messaging +(`window.postMessage` / `chrome.runtime`) and are not affected by `connect-src`. + ## Security Checklist ### Frontend ✅ diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 8d04de0b..d6f3fdb5 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -8,14 +8,14 @@ server { add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://soroban-testnet.stellar.org https://horizon-testnet.stellar.org https://soroban-mainnet.stellar.org https://horizon.stellar.org; font-src 'self'; frame-ancestors 'none'" always; add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always; location /embed/ { add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors *" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://soroban-testnet.stellar.org https://horizon-testnet.stellar.org https://soroban-mainnet.stellar.org https://horizon.stellar.org; frame-ancestors *" always; try_files $uri $uri/ /index.html; } From 62549cf3c588ec945f00c54f2625ebeb58f3906f Mon Sep 17 00:00:00 2001 From: PrimeFactor-Dev Date: Wed, 21 Oct 2026 07:22:02 +0100 Subject: [PATCH 2/4] ci(e2e): run E2E tests against vite preview production build Include frontend/e2e/ specs in Playwright testMatch so they run alongside tests/e2e/. Add e2e-prod-build CI job that builds and tests against vite preview, blocking merges to main. Document the setup in E2E_TESTING.md. Closes #1487 --- .github/workflows/frontend-ci.yml | 38 +++++++++++++++++++++++++++++++ docs/E2E_TESTING.md | 17 ++++++++++++++ frontend/playwright.config.js | 3 ++- 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index f3d4552b..106a5104 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -192,3 +192,41 @@ jobs: else echo "⚠️ No test report generated for ${{ matrix.browser }}" >> $GITHUB_STEP_SUMMARY fi + + e2e-prod-build: + name: E2E against production build (vite preview) + runs-on: ubuntu-latest + needs: frontend + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build frontend + run: npm run build --workspace=frontend + + - name: Install Playwright with dependencies + run: npx playwright install --with-deps chromium + + - name: Run E2E against vite preview (production build) + run: npx playwright test --project=chromium + working-directory: frontend + + - name: Upload prod-build E2E results + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-prod-build-results + path: | + frontend/playwright-report + frontend/test-results + if-no-files-found: ignore diff --git a/docs/E2E_TESTING.md b/docs/E2E_TESTING.md index 7ef99f77..768f6f39 100644 --- a/docs/E2E_TESTING.md +++ b/docs/E2E_TESTING.md @@ -242,6 +242,23 @@ jobs: retention-days: 7 ``` +### Production Build E2E (#1487) + +E2E tests now include `frontend/e2e/` specs alongside `frontend/tests/e2e/` and run against +`vite preview` (the production build), catching bugs that only appear in the optimized bundle. + +The `e2e-prod-build` job in `.github/workflows/frontend-ci.yml` builds the frontend, starts +`vite preview`, and runs Playwright against the production output. This job is required and +blocks merges to `main`. + +To run locally against the production build: + +```bash +cd frontend +npm run build +npx playwright test --project=chromium +``` + ## Debugging Tests ### View Test Report diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js index a1bb49ad..60fc39da 100644 --- a/frontend/playwright.config.js +++ b/frontend/playwright.config.js @@ -1,7 +1,8 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ - testDir: './tests/e2e', + testDir: './', + testMatch: ['**/tests/e2e/**/*.spec.{js,ts}', '**/e2e/**/*.spec.{js,ts}'], timeout: 30_000, retries: process.env.CI ? 1 : 0, reporter: process.env.CI ? 'github' : 'list', From b7b4e43b5d823d12c80f8e52ae085adebe5f5ffc Mon Sep 17 00:00:00 2001 From: PrimeFactor-Dev Date: Wed, 21 Oct 2026 07:32:03 +0100 Subject: [PATCH 3/4] test(frontend): verify in-flight search requests are cancelled on new query Add unit test confirming stale campaign fetch results are discarded when a new search starts, complementing the existing debounce test coverage. Closes #1489 --- frontend/src/Landing.test.jsx | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/frontend/src/Landing.test.jsx b/frontend/src/Landing.test.jsx index 284ee89b..1ff42df8 100644 --- a/frontend/src/Landing.test.jsx +++ b/frontend/src/Landing.test.jsx @@ -101,6 +101,41 @@ describe('Landing campaign list (#919)', () => { vi.useRealTimers(); }); + it('cancels in-flight requests when a new search starts', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + let resolveFirst; + const firstCall = new Promise((r) => { + resolveFirst = r; + }); + apiClient.getCampaigns + .mockImplementationOnce(() => firstCall) + .mockResolvedValue(makePayload([makeCampaign()])); + + renderLanding(); + await waitFor(() => expect(apiClient.getCampaigns).toHaveBeenCalledTimes(1)); + + const input = await screen.findByLabelText(/search campaigns/i); + act(() => { + fireEvent.change(input, { target: { value: 'alpha' } }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + await waitFor(() => expect(apiClient.getCampaigns).toHaveBeenCalledTimes(2)); + + // Resolve the stale first call with a different campaign — must be discarded. + resolveFirst(makePayload([makeCampaign({ id: 'stale', name: 'Stale Campaign' })])); + await act(async () => { + await Promise.resolve(); + }); + + await waitFor(() => expect(screen.getByText('Airdrop Alpha')).toBeInTheDocument()); + expect(screen.queryByText('Stale Campaign')).not.toBeInTheDocument(); + + vi.useRealTimers(); + }); + it('toggling active-only refetches with active=true', async () => { apiClient.getCampaigns.mockResolvedValue(makePayload([makeCampaign()])); From ba9506979e16141f4804d138c73f9f1e50b980a7 Mon Sep 17 00:00:00 2001 From: PrimeFactor-Dev Date: Wed, 21 Oct 2026 07:33:18 +0100 Subject: [PATCH 4/4] feat(infra): add backup verification worker restoring to isolated sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create scripts/verify-backup.sh that discovers the latest backup, restores it to an isolated sandbox database, runs integrity checks, and reports pass/fail — enabling automated daily backup verification. Closes #1348 --- scripts/verify-backup.sh | 186 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100755 scripts/verify-backup.sh diff --git a/scripts/verify-backup.sh b/scripts/verify-backup.sh new file mode 100755 index 00000000..398e6f78 --- /dev/null +++ b/scripts/verify-backup.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# scripts/verify-backup.sh — Verify a database backup restores cleanly to an isolated sandbox. +# +# Usage: +# DATABASE_URL=postgresql://... ./scripts/verify-backup.sh [backup-path] +# +# Arguments: +# $1 — (optional) Local file path or S3 URI of the backup to verify. +# If omitted, the latest backup is discovered from UPLOAD_DIR or S3. +# +# Environment variables: +# DATABASE_URL — PostgreSQL connection string for the primary DB (required) +# BACKUP_DECRYPTION_KEY — Private key file for age decryption (if encrypted) +# UPLOAD_DIR — Local backup directory (default: ./backups) +# S3_BUCKET — S3 bucket for remote backups +# SANDBOX_DB_NAME — Isolated sandbox DB name (default: trivela_backup_verify) +# VERIFICATION_TIMEOUT — Max seconds for the verification (default: 300) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname "$SCRIPT_DIR")" + +# ── Configuration ───────────────────────────────────────────────────────────── +DATABASE_URL="${DATABASE_URL:?DATABASE_URL is required}" +BACKUP_DECRYPTION_KEY="${BACKUP_DECRYPTION_KEY:-}" +UPLOAD_DIR="${UPLOAD_DIR:-$REPO_ROOT/backups}" +S3_BUCKET="${S3_BUCKET:-}" +SANDBOX_DB_NAME="${SANDBOX_DB_NAME:-trivela_backup_verify}" +VERIFICATION_TIMEOUT="${VERIFICATION_TIMEOUT:-300}" +BACKUP_SOURCE="${1:-}" + +# ── Pre-flight checks ──────────────────────────────────────────────────────── +command -v psql >/dev/null 2>&1 || { echo "ERROR: psql not found"; exit 1; } +command -v pg_restore >/dev/null 2>&1 || { echo "ERROR: pg_restore not found"; exit 1; } + +TMP_DIR="$(mktemp -d)" +cleanup() { + # Drop sandbox DB + psql "$DATABASE_URL" -c "DROP DATABASE IF EXISTS ${SANDBOX_DB_NAME};" >/dev/null 2>&1 || true + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +echo "=== Backup Verification Worker ===" +echo "Sandbox DB: ${SANDBOX_DB_NAME}" +echo "" + +# ── Discover latest backup if not provided ─────────────────────────────────── +if [[ -z "$BACKUP_SOURCE" ]]; then + echo "No backup specified, discovering latest..." + if [[ -n "$S3_BUCKET" ]]; then + BACKUP_SOURCE=$(aws s3 ls "s3://${S3_BUCKET}/backups/" --recursive 2>/dev/null \ + | sort | tail -1 | awk '{print "s3://'"${S3_BUCKET}"'/"$4}') + if [[ -z "$BACKUP_SOURCE" ]]; then + echo "ERROR: No backups found in s3://${S3_BUCKET}/backups/" >&2 + exit 1 + fi + else + BACKUP_SOURCE=$(find "$UPLOAD_DIR" -name 'trivela-backup-*' -type f \ + ! -name '*.sha256' 2>/dev/null | sort | tail -1) + if [[ -z "$BACKUP_SOURCE" ]]; then + echo "ERROR: No backups found in ${UPLOAD_DIR}" >&2 + exit 1 + fi + fi +fi +echo "Backup: ${BACKUP_SOURCE}" +echo "" + +# ── Fetch backup ────────────────────────────────────────────────────────────── +if [[ "$BACKUP_SOURCE" == s3://* ]]; then + command -v aws >/dev/null 2>&1 || { echo "ERROR: AWS CLI not found"; exit 1; } + echo "Downloading backup from S3..." + aws s3 cp "$BACKUP_SOURCE" "$TMP_DIR/backup" + BACKUP_FILE="$TMP_DIR/backup" +elif [[ -f "$BACKUP_SOURCE" ]]; then + cp "$BACKUP_SOURCE" "$TMP_DIR/backup" + BACKUP_FILE="$TMP_DIR/backup" +else + echo "ERROR: Backup source not found: $BACKUP_SOURCE" >&2 + exit 1 +fi + +# ── Verify checksum ─────────────────────────────────────────────────────────── +CHECKSUM_SOURCE="${BACKUP_SOURCE%.*}" +if [[ "$BACKUP_SOURCE" == s3://* ]]; then + CHECKSUM_FILE="${CHECKSUM_SOURCE}.sha256" + if aws s3 ls "$CHECKSUM_FILE" >/dev/null 2>&1; then + aws s3 cp "$CHECKSUM_FILE" "$TMP_DIR/checksum.txt" + fi +elif [[ -f "${BACKUP_SOURCE%.*}.sha256" ]]; then + cp "${BACKUP_SOURCE%.*}.sha256" "$TMP_DIR/checksum.txt" +fi + +if [[ -f "$TMP_DIR/checksum.txt" ]]; then + echo "Verifying checksum..." + if echo "$(cat "$TMP_DIR/checksum.txt") $BACKUP_FILE" | sha256sum -c -; then + echo "✓ Checksum verified" + else + echo "ERROR: Checksum verification failed" >&2 + exit 1 + fi +else + echo "WARNING: No checksum file found, skipping checksum verification" +fi + +# ── Decrypt (optional) ──────────────────────────────────────────────────────── +if [[ -n "$BACKUP_DECRYPTION_KEY" ]]; then + command -v age >/dev/null 2>&1 || { echo "ERROR: age not found"; exit 1; } + echo "Decrypting backup..." + age -d -i "$BACKUP_DECRYPTION_KEY" -o "$TMP_DIR/backup.dec" "$BACKUP_FILE" + BACKUP_FILE="$TMP_DIR/backup.dec" + echo "✓ Decrypted" +fi + +# ── Decompress ──────────────────────────────────────────────────────────────── +if file "$BACKUP_FILE" | grep -q gzip; then + echo "Decompressing..." + gunzip -f "$BACKUP_FILE" + BACKUP_FILE="${BACKUP_FILE%.gz}" +fi + +# ── Create isolated sandbox database ───────────────────────────────────────── +echo "Creating isolated sandbox database '${SANDBOX_DB_NAME}'..." +psql "$DATABASE_URL" -c "DROP DATABASE IF EXISTS ${SANDBOX_DB_NAME};" >/dev/null +psql "$DATABASE_URL" -c "CREATE DATABASE ${SANDBOX_DB_NAME};" >/dev/null + +SANDBOX_URL="${DATABASE_URL%/*}/${SANDBOX_DB_NAME}" +echo "✓ Sandbox ready" + +# ── Restore into sandbox ───────────────────────────────────────────────────── +echo "Restoring backup into sandbox..." +if ! pg_restore --clean --if-exists --no-owner --no-privileges \ + --dbname "$SANDBOX_URL" "$BACKUP_FILE" 2>"$TMP_DIR/restore-errors.log"; then + # pg_restore exits non-zero on non-fatal warnings; check if the restore actually failed + if [[ -s "$TMP_DIR/restore-errors.log" ]] && ! psql "$SANDBOX_URL" -t -A -c "SELECT 1;" >/dev/null 2>&1; then + echo "ERROR: Restore failed" >&2 + cat "$TMP_DIR/restore-errors.log" >&2 + exit 1 + fi + echo "WARNING: Restore completed with warnings (non-fatal)" +else + echo "✓ Restore complete" +fi + +# ── Run verification queries ───────────────────────────────────────────────── +echo "" +echo "Running verification queries..." +FAILED=0 + +verify_query() { + local label="$1" + local sql="$2" + local expected_min="${3:-0}" + local result + result=$(psql "$SANDBOX_URL" -t -A -c "$sql" 2>/dev/null || echo "-1") + if [[ "$result" == "-1" ]]; then + echo " ✗ ${label}: query failed" + FAILED=$((FAILED + 1)) + elif [[ "$result" -lt "$expected_min" ]]; then + echo " ✗ ${label}: ${result} rows (expected >= ${expected_min})" + FAILED=$((FAILED + 1)) + else + echo " ✓ ${label}: ${result} rows" + fi +} + +verify_query "Schema migrations" "SELECT COUNT(*) FROM _schema_migrations" 1 +verify_query "Campaigns table" "SELECT COUNT(*) FROM campaigns" 0 +verify_query "Audit logs table" "SELECT COUNT(*) FROM audit_logs" 0 +verify_query "API keys table" "SELECT COUNT(*) FROM api_keys" 0 + +# Integrity: check for orphaned foreign keys in campaigns +ORPHANS=$(psql "$SANDBOX_URL" -t -A -c \ + "SELECT COUNT(*) FROM campaigns c WHERE NOT EXISTS (SELECT 1 FROM api_keys k WHERE k.id = c.created_by)" 2>/dev/null || echo "0") +echo " ℹ Orphaned campaign references: ${ORPHANS}" + +echo "" +if [[ "$FAILED" -gt 0 ]]; then + echo "ERROR: ${FAILED} verification check(s) failed" >&2 + exit 1 +fi + +echo "✓ Backup verification passed" +echo "✓ Backup restores cleanly without data loss"