From be3e69c10b3c6775b741ef029707fa8ef38619cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 09:58:12 +0100 Subject: [PATCH] Security hardening: headers, scanning, Docker, firewall - Caddy: HSTS, CSP, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy on all responses - Caddy: Block scanner paths (.env, .git, wp-config, etc.) with 403 - CI: Gitleaks secret scanning + pnpm audit for vulnerabilities - Dependabot: Weekly npm + GitHub Actions + monthly Docker updates - Docker: Non-root user in journal, planner, and brouter containers - Server: UFW firewall (22/80/443 only) + fail2ban (8 IPs already banned) - SECURITY.md: Vulnerability disclosure policy - Privacy page: Security practices section added Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/dependabot.yml | 30 +++++++++++ .github/workflows/ci.yml | 21 ++++++++ .gitleaks.toml | 7 +++ SECURITY.md | 44 ++++++++++++++++ apps/journal/Dockerfile | 2 + apps/journal/app/routes/privacy.tsx | 12 +++++ apps/planner/Dockerfile | 2 + docker/brouter/Dockerfile | 6 ++- docs/server-hardening.md | 53 ++++++++++++++++++++ infrastructure/Caddyfile | 20 ++++++++ openspec/changes/security-hardening/tasks.md | 32 ++++++------ 11 files changed, 211 insertions(+), 18 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .gitleaks.toml create mode 100644 SECURITY.md create mode 100644 docs/server-hardening.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a888efe --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + groups: + production: + update-types: + - "minor" + - "patch" + development: + dependency-type: "development" + open-pull-requests-limit: 10 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + + - package-ecosystem: "docker" + directory: "/apps/journal" + schedule: + interval: "monthly" + + - package-ecosystem: "docker" + directory: "/apps/planner" + schedule: + interval: "monthly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6a6846..3eab10a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,27 @@ concurrency: cancel-in-progress: true jobs: + security: + name: Security Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Dependency audit + run: pnpm audit --audit-level=high + continue-on-error: true + typecheck: name: Typecheck runs-on: ubuntu-latest diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..a3f3fed --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,7 @@ +[extend] +# Use default gitleaks rules + +[[allowlist.regexTarget]] +# Sentry DSNs are public (ingest-only, not secrets) +description = "Sentry DSN" +regex = '''https://[a-f0-9]+@o\d+\.ingest\.\w+\.sentry\.io/\d+''' diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..cd52f74 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,44 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in trails.cool, please report it +responsibly. + +**Do NOT open a public GitHub issue for security vulnerabilities.** + +Instead, please email: **security@trails.cool** + +Include: +- Description of the vulnerability +- Steps to reproduce +- Impact assessment (what could an attacker do?) +- Any suggested fixes + +We will acknowledge your report within 48 hours and provide a timeline for +a fix. We aim to resolve critical vulnerabilities within 7 days. + +## Scope + +The following are in scope: +- trails.cool and planner.trails.cool (production) +- The trails-cool/trails GitHub repository +- Authentication, authorization, and session management +- Data exposure or leakage +- Server-side vulnerabilities + +The following are out of scope: +- Denial of service attacks +- Social engineering +- Self-hosted instances (report to the instance operator) +- Issues in third-party dependencies (report upstream) + +## Security Practices + +- **Authentication**: Passkey (WebAuthn) + magic link login. No passwords stored. +- **Cookies**: httpOnly, secure, sameSite=lax +- **Database**: Parameterized queries via Drizzle ORM (no raw SQL) +- **Headers**: HSTS, CSP, X-Frame-Options, X-Content-Type-Options +- **CI**: Gitleaks secret scanning, dependency auditing +- **Docker**: Non-root containers +- **Monitoring**: Sentry error tracking, fail2ban SSH protection diff --git a/apps/journal/Dockerfile b/apps/journal/Dockerfile index 036454a..1320ba3 100644 --- a/apps/journal/Dockerfile +++ b/apps/journal/Dockerfile @@ -21,6 +21,7 @@ COPY . . RUN pnpm --filter @trails-cool/journal build FROM base AS runtime +RUN addgroup --system app && adduser --system --ingroup app app COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/journal/node_modules ./apps/journal/node_modules COPY --from=build /app/apps/journal/build ./apps/journal/build @@ -28,6 +29,7 @@ COPY --from=build /app/apps/journal/package.json ./apps/journal/package.json COPY --from=build /app/packages ./packages WORKDIR /app/apps/journal +USER app EXPOSE 3000 ENV PORT=3000 CMD ["npx", "react-router-serve", "./build/server/index.js"] diff --git a/apps/journal/app/routes/privacy.tsx b/apps/journal/app/routes/privacy.tsx index b0c1fd0..012ad02 100644 --- a/apps/journal/app/routes/privacy.tsx +++ b/apps/journal/app/routes/privacy.tsx @@ -90,6 +90,18 @@ export default function PrivacyPage() { +
+

Security Practices

+
    +
  • Authentication: Passkey (WebAuthn) and magic link login. No passwords stored.
  • +
  • Encryption: All traffic over HTTPS with HSTS preload. Cookies are httpOnly and secure.
  • +
  • Headers: Content-Security-Policy, X-Frame-Options, X-Content-Type-Options on all responses.
  • +
  • Infrastructure: Docker containers run as non-root. Firewall restricts to HTTP/HTTPS/SSH only.
  • +
  • CI/CD: Gitleaks secret scanning and dependency auditing on every pull request.
  • +
  • Vulnerability reporting: See our SECURITY.md for responsible disclosure.
  • +
+
+

Last updated: March 2026. If this manifest changes, we'll note it here.

diff --git a/apps/planner/Dockerfile b/apps/planner/Dockerfile index 49ba1c6..b8810df 100644 --- a/apps/planner/Dockerfile +++ b/apps/planner/Dockerfile @@ -21,6 +21,7 @@ COPY . . RUN pnpm --filter @trails-cool/planner build FROM base AS runtime +RUN addgroup --system app && adduser --system --ingroup app app COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/planner/node_modules ./apps/planner/node_modules COPY --from=build /app/apps/planner/build ./apps/planner/build @@ -30,6 +31,7 @@ COPY --from=build /app/apps/planner/package.json ./apps/planner/package.json COPY --from=build /app/packages ./packages WORKDIR /app/apps/planner +USER app EXPOSE 3001 ENV PORT=3001 CMD ["node", "--experimental-strip-types", "server.ts"] diff --git a/docker/brouter/Dockerfile b/docker/brouter/Dockerfile index 8ff91d5..6ea7073 100644 --- a/docker/brouter/Dockerfile +++ b/docker/brouter/Dockerfile @@ -12,13 +12,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends wget unzip curl && rm "brouter-${BROUTER_VERSION}.zip" \ && apt-get purge -y wget unzip && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* -# Create directories for segments and profiles -RUN mkdir -p /data/segments /data/profiles +# Create non-root user and directories for segments and profiles +RUN addgroup --system app && adduser --system --ingroup app app \ + && mkdir -p /data/segments /data/profiles # Copy default profiles from release and rename JAR for simpler CMD RUN cp -r profiles2/* /data/profiles/ 2>/dev/null || true \ && mv brouter-*-all.jar brouter.jar +USER app EXPOSE 17777 # BRouter server: diff --git a/docs/server-hardening.md b/docs/server-hardening.md new file mode 100644 index 0000000..c612c04 --- /dev/null +++ b/docs/server-hardening.md @@ -0,0 +1,53 @@ +# Server Hardening + +Steps to harden the Hetzner CX21 production server. + +## UFW Firewall + +```bash +# Install and enable +apt install -y ufw +ufw default deny incoming +ufw default allow outgoing +ufw allow 22/tcp # SSH +ufw allow 80/tcp # HTTP (Caddy redirect) +ufw allow 443/tcp # HTTPS +ufw enable +ufw status +``` + +## Fail2ban (SSH protection) + +```bash +# Install +apt install -y fail2ban + +# Create config +cat > /etc/fail2ban/jail.local << 'EOF' +[sshd] +enabled = true +port = ssh +filter = sshd +logpath = /var/log/auth.log +maxretry = 5 +bantime = 3600 +findtime = 600 +EOF + +# Start +systemctl enable fail2ban +systemctl start fail2ban + +# Check status +fail2ban-client status sshd +``` + +## SSH Hardening + +Already in place: +- Key-based auth only (password auth disabled by Hetzner cloud-init) +- Root login via SSH key only + +Optional improvements: +- Change SSH port (security through obscurity, mild benefit) +- Add `AllowUsers root` to `/etc/ssh/sshd_config` to restrict SSH users diff --git a/infrastructure/Caddyfile b/infrastructure/Caddyfile index 1a8a3f2..f4a4fea 100644 --- a/infrastructure/Caddyfile +++ b/infrastructure/Caddyfile @@ -1,4 +1,22 @@ +(security_headers) { + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; connect-src 'self' wss: https://*.sentry.io https://*.ingest.de.sentry.io; font-src 'self';" + } +} + +(block_scanners) { + @scanners path /.env* /.git* /wp-* /wp-admin* /admin* /config.* /backup* /.aws* /.docker* /composer* /vendor* *.php *.asp + respond @scanners 403 +} + {$DOMAIN:trails.cool} { + import security_headers + import block_scanners reverse_proxy journal:3000 } @@ -7,5 +25,7 @@ www.{$DOMAIN:trails.cool} { } planner.{$DOMAIN:trails.cool} { + import security_headers + import block_scanners reverse_proxy planner:3001 } diff --git a/openspec/changes/security-hardening/tasks.md b/openspec/changes/security-hardening/tasks.md index f5e37cd..5f954a1 100644 --- a/openspec/changes/security-hardening/tasks.md +++ b/openspec/changes/security-hardening/tasks.md @@ -1,44 +1,44 @@ ## 1. Caddy Security Headers -- [ ] 1.1 Add security headers block to Caddyfile: HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy -- [ ] 1.2 Add Content-Security-Policy header (permissive initial policy allowing React Router inline scripts, OSM tiles, Sentry, WebSocket) +- [x] 1.1 Add security headers block to Caddyfile: HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy +- [x] 1.2 Add Content-Security-Policy header (permissive initial policy allowing React Router inline scripts, OSM tiles, Sentry, WebSocket) - [ ] 1.3 Test headers with securityheaders.com or curl after deploy ## 2. Scanner Blocking -- [ ] 2.1 Add Caddy path matcher for common scanner targets (.env*, .git*, wp-*, admin*, config.*, backup*) -- [ ] 2.2 Return 403 for matched paths before reverse_proxy +- [x] 2.1 Add Caddy path matcher for common scanner targets (.env*, .git*, wp-*, admin*, config.*, backup*) +- [x] 2.2 Return 403 for matched paths before reverse_proxy - [ ] 2.3 Verify scanner paths return 403, normal paths still work ## 3. CI Secret Scanning -- [ ] 3.1 Add gitleaks/gitleaks-action@v2 step to CI workflow -- [ ] 3.2 Create .gitleaks.toml with allowlist for Sentry DSNs (public values) +- [x] 3.1 Add gitleaks/gitleaks-action@v2 step to CI workflow +- [x] 3.2 Create .gitleaks.toml with allowlist for Sentry DSNs (public values) - [ ] 3.3 Verify gitleaks passes on current codebase ## 4. Dependency Auditing -- [ ] 4.1 Add `pnpm audit --audit-level=high` step to CI workflow -- [ ] 4.2 Create .github/dependabot.yml for weekly pnpm dependency updates +- [x] 4.1 Add `pnpm audit --audit-level=high` step to CI workflow +- [x] 4.2 Create .github/dependabot.yml for weekly pnpm dependency updates - [ ] 4.3 Verify pnpm audit passes on current codebase ## 5. Docker Hardening -- [ ] 5.1 Add non-root user to Journal Dockerfile runtime stage -- [ ] 5.2 Add non-root user to Planner Dockerfile runtime stage -- [ ] 5.3 Add non-root user to BRouter Dockerfile +- [x] 5.1 Add non-root user to Journal Dockerfile runtime stage +- [x] 5.2 Add non-root user to Planner Dockerfile runtime stage +- [x] 5.3 Add non-root user to BRouter Dockerfile - [ ] 5.4 Test containers start and serve correctly as non-root ## 6. Server Hardening -- [ ] 6.1 Install and configure fail2ban on Hetzner server (SSH protection) -- [ ] 6.2 Configure UFW firewall (allow 22, 80, 443 only) -- [ ] 6.3 Document server hardening steps in deployment docs +- [x] 6.1 Install and configure fail2ban on Hetzner server (SSH protection) +- [x] 6.2 Configure UFW firewall (allow 22, 80, 443 only) +- [x] 6.3 Document server hardening steps in deployment docs ## 7. Documentation -- [ ] 7.1 Create SECURITY.md with vulnerability disclosure policy and security contact -- [ ] 7.2 Update privacy manifest with security practices documented +- [x] 7.1 Create SECURITY.md with vulnerability disclosure policy and security contact +- [x] 7.2 Update privacy manifest with security practices documented ## 8. Verify