Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ HOST=0.0.0.0
# Timeout per handler in milliseconds
API_TIMEOUT_LIMIT=30000

# CORS origin (use * for any, or specific domain)
API_CORS_ORIGIN=*
# CORS allowlist (comma-separated). Empty/unset = CORS disabled.
API_CORS_ORIGIN=

# Rate limit: max requests per IP per time window (default: 100 / 10 minutes)
# RATE_LIMIT_MAX=100
# RATE_LIMIT_WINDOW=10 minutes
# Global rate limit
RATE_LIMIT_MAX=200
RATE_LIMIT_WINDOW=1 minute

# Maximum concurrent handlers per scan (default: 8)
# MAX_CONCURRENCY=8
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ permissions:
contents: read
packages: write
security-events: write
id-token: write # required for cosign keyless OIDC signing

jobs:
# ── Stage 1: Lint + Typecheck ──────────────────────────────
Expand Down Expand Up @@ -159,3 +160,28 @@ jobs:
${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:${{ github.event.release.tag_name }}
${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:latest
cache-from: type=gha,scope=${{ matrix.image }}

- name: Install cosign
uses: sigstore/cosign-installer@v3

- name: Sign image with cosign (keyless)
env:
COSIGN_EXPERIMENTAL: "1"
run: |
cosign sign --yes \
${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:${{ github.event.release.tag_name }}
cosign sign --yes \
${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:latest

- name: Generate SBOM with syft
uses: anchore/sbom-action@v0
with:
image: ${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:${{ github.event.release.tag_name }}
format: spdx-json
output-file: sbom-${{ matrix.image }}.spdx.json
upload-artifact: false

- name: Attach SBOM to GitHub release
uses: softprops/action-gh-release@v2
with:
files: sbom-${{ matrix.image }}.spdx.json
80 changes: 80 additions & 0 deletions .releases/1.2.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# v1.2.0

Security hardening release. Adds full SSRF protection, Chromium sandbox,
non-root containers, strict CSP, signed images, and SBOMs.

## Highlights

- **SSRF hardening** — All outbound HTTP from analysis handlers now goes
through `safeFetch`, which validates the URL protocol, resolves DNS,
rejects private/internal IPs (RFC1918, CGNAT, link-local, loopback,
multicast, IPv6 ULA, IPv4-mapped, cloud metadata 169.254.169.254),
pins the connection to the validated IP to defeat DNS rebinding, and
re-validates every redirect target. Set `RECON_ALLOW_PRIVATE_IPS=1`
to opt out for local testing.
- **Chromium sandbox** — The screenshot handler no longer launches
Chromium with `--no-sandbox`. The container needs `cap_add: SYS_ADMIN`
+ `security_opt: seccomp=unconfined` for the user-namespace sandbox to
initialize. Application-layer SSRF guard remains the primary defense.
- **Non-root containers** — `api`, `web`, and `cli` images all run as
non-root (UID 10001 for api/cli, UID 101 nginx for web). Read-only
rootfs, `cap_drop: ALL`, `no-new-privileges`. Web nginx now listens on
8080 (unprivileged) and the compose port mapping is updated.
- **Strict CSP + security headers** — `Content-Security-Policy`,
`X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`,
`Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy`
baked into the nginx config. HSTS opt-in via `HSTS_HEADER` env.
- **Signed images** — Release images are signed with cosign keyless OIDC
via GitHub Actions identity. Verify with
`cosign verify --certificate-identity-regexp 'https://github.com/BrunoAFK/recon-web' --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' ghcr.io/brunoafk/recon-web/api:v1.2.0`.
- **SBOM** — Each release image now has an SPDX-JSON SBOM attached to
the GitHub release artifacts.

## Breaking changes

- **CORS default is now `false`** (was `*`). To allow cross-origin
browser access, set `API_CORS_ORIGIN=https://your.app` (comma-separated
for multiple origins).
- **Web container listens on 8080 internally** (was 80). The compose
file is updated; if you wrote your own deployment, update the upstream
port mapping.
- **Helm chart removed.** It was unmaintained and not production-hardened.
Use Docker Compose for production. K8s users can write a fresh chart
against the v1.2.0 images.
- **Chromium screenshot requires elevated container privileges**
(`SYS_ADMIN` + `seccomp=unconfined`). The compose file already sets
these. If you run the api container outside compose, you must add
these flags or screenshots will fail with a sandbox error.

## Bug fixes

- Default rate limit raised from 100 / 10 minutes (effectively no
per-burst protection) to 200 / 1 minute. Per-route auth limits are
added by downstream overlays.
- `isPrivateIP` now correctly handles IPv4-mapped IPv6 addresses, IPv6
ULAs, link-local, multicast, and CGNAT — previously it missed all of
these.

## CI/CD

- New `id-token: write` permission for cosign OIDC.
- New cosign install + sign step in the `docker-push` job for all three
images on every release.
- New syft SBOM generation + GitHub release artifact attach.

## Docker images

```bash
docker pull ghcr.io/brunoafk/recon-web/api:v1.2.0
docker pull ghcr.io/brunoafk/recon-web/web:v1.2.0
docker pull ghcr.io/brunoafk/recon-web/cli:v1.2.0
```

Verify signatures:

```bash
cosign verify \
--certificate-identity-regexp 'https://github.com/BrunoAFK/recon-web/.github/workflows/ci.yml@.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/brunoafk/recon-web/api:v1.2.0
```
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,29 @@ docker run --rm ghcr.io/brunoafk/recon-web/cli scan example.com

---

## Security

recon-web v1.2.0+ ships with:

- **SSRF protection** — All analysis handlers validate URLs against an
IP allowlist (no RFC1918, no link-local, no cloud metadata) and pin
connections to the validated IP to defeat DNS rebinding. Set
`RECON_ALLOW_PRIVATE_IPS=1` if you intentionally want to scan internal
hosts (lab environments only).
- **Chromium sandbox** — Screenshot handler runs Chromium with the
user-namespace sandbox enabled. The container needs `SYS_ADMIN` +
`seccomp=unconfined` (already set in the bundled compose file).
- **Non-root containers** — All images run as unprivileged users with
read-only rootfs and dropped capabilities.
- **Strict CSP** — Web frontend ships a strict Content-Security-Policy.
- **Signed images** — Release images are cosign-signed via GitHub
Actions OIDC. Verify before pulling in production.

To report a security issue, please open a private security advisory on
GitHub rather than a public issue.

---

## Configuration

Copy `.env.example` to `.env`. Everything is optional — the app works out of the box without any API keys.
Expand Down
32 changes: 31 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,43 @@ services:
interval: 30s
timeout: 5s
start_period: 10s
user: "10001:10001"
# Required for Chromium user-namespace sandbox in screenshot handler.
# Application-layer SSRF guard (utils/safe-fetch.ts, utils/network.ts) is
# the primary defense — the sandbox is defense in depth.
cap_drop:
- ALL
cap_add:
- SYS_ADMIN
security_opt:
- seccomp=unconfined
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp
- /home/app/.cache

web:
build:
context: .
dockerfile: packages/web/Dockerfile
ports:
- "${WEB_PORT:-8080}:80"
- "${WEB_PORT:-8080}:8080"
depends_on:
api:
condition: service_healthy
environment:
- API_UPSTREAM=http://api:3000
user: "101:101"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /var/cache/nginx:uid=101,gid=101
- /run:uid=101,gid=101
- /etc/nginx/conf.d:uid=101,gid=101

cli:
build:
Expand All @@ -39,6 +64,11 @@ services:
volumes:
- scan-data:/app/data
profiles: ["cli"]
user: "10001:10001"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true

volumes:
scan-data:
6 changes: 0 additions & 6 deletions helm/recon-web/Chart.yaml

This file was deleted.

62 changes: 0 additions & 62 deletions helm/recon-web/templates/api-deployment.yaml

This file was deleted.

18 changes: 0 additions & 18 deletions helm/recon-web/templates/api-service.yaml

This file was deleted.

41 changes: 0 additions & 41 deletions helm/recon-web/templates/configmap.yaml

This file was deleted.

29 changes: 0 additions & 29 deletions helm/recon-web/templates/cronjob.yaml

This file was deleted.

Loading