diff --git a/.env.example b/.env.example index 0efe8e1..57ba648 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8910fc..d8c9e04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 ────────────────────────────── @@ -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 diff --git a/.releases/1.2.0.md b/.releases/1.2.0.md new file mode 100644 index 0000000..1ee5b36 --- /dev/null +++ b/.releases/1.2.0.md @@ -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 +``` diff --git a/README.md b/README.md index abbe167..405ef80 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index 467d983..29cb07e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: @@ -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: diff --git a/helm/recon-web/Chart.yaml b/helm/recon-web/Chart.yaml deleted file mode 100644 index 52f8ad2..0000000 --- a/helm/recon-web/Chart.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v2 -name: recon-web -description: OSINT website analysis tool — Helm chart for Kubernetes deployment -type: application -version: 0.1.0 -appVersion: "1.0.0" diff --git a/helm/recon-web/templates/api-deployment.yaml b/helm/recon-web/templates/api-deployment.yaml deleted file mode 100644 index 2f5e108..0000000 --- a/helm/recon-web/templates/api-deployment.yaml +++ /dev/null @@ -1,62 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ .Release.Name }}-api - labels: - app.kubernetes.io/name: recon-web - app.kubernetes.io/component: api - app.kubernetes.io/instance: {{ .Release.Name }} -spec: - replicas: {{ .Values.api.replicas }} - selector: - matchLabels: - app.kubernetes.io/name: recon-web - app.kubernetes.io/component: api - app.kubernetes.io/instance: {{ .Release.Name }} - template: - metadata: - labels: - app.kubernetes.io/name: recon-web - app.kubernetes.io/component: api - app.kubernetes.io/instance: {{ .Release.Name }} - spec: - containers: - - name: api - image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}" - imagePullPolicy: {{ .Values.api.image.pullPolicy }} - ports: - - containerPort: 3000 - protocol: TCP - envFrom: - - configMapRef: - name: {{ .Release.Name }}-config - - secretRef: - name: {{ .Release.Name }}-secret - env: - - name: DB_PATH - value: /data/recon-web.db - {{- if .Values.persistence.enabled }} - volumeMounts: - - name: data - mountPath: /data - {{- end }} - livenessProbe: - httpGet: - path: /health - port: 3000 - initialDelaySeconds: 10 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /health - port: 3000 - initialDelaySeconds: 5 - periodSeconds: 10 - resources: - {{- toYaml .Values.api.resources | nindent 12 }} - {{- if .Values.persistence.enabled }} - volumes: - - name: data - persistentVolumeClaim: - claimName: {{ .Release.Name }}-data - {{- end }} diff --git a/helm/recon-web/templates/api-service.yaml b/helm/recon-web/templates/api-service.yaml deleted file mode 100644 index 7baa167..0000000 --- a/helm/recon-web/templates/api-service.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ .Release.Name }}-api - labels: - app.kubernetes.io/name: recon-web - app.kubernetes.io/component: api - app.kubernetes.io/instance: {{ .Release.Name }} -spec: - type: ClusterIP - ports: - - port: 3000 - targetPort: 3000 - protocol: TCP - selector: - app.kubernetes.io/name: recon-web - app.kubernetes.io/component: api - app.kubernetes.io/instance: {{ .Release.Name }} diff --git a/helm/recon-web/templates/configmap.yaml b/helm/recon-web/templates/configmap.yaml deleted file mode 100644 index 5fe58d1..0000000 --- a/helm/recon-web/templates/configmap.yaml +++ /dev/null @@ -1,41 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ .Release.Name }}-config - labels: - app.kubernetes.io/name: recon-web - app.kubernetes.io/instance: {{ .Release.Name }} -data: - NODE_ENV: "production" - HOST: "0.0.0.0" - PORT: "3000" - MAX_CONCURRENCY: {{ .Values.api.env.MAX_CONCURRENCY | default "8" | quote }} - MAX_CONCURRENT_SCANS: {{ .Values.auth.maxConcurrentScans | default 3 | quote }} - {{- if .Values.auth.provider }} - AUTH_PROVIDER: {{ .Values.auth.provider | quote }} - REGISTRATION_OPEN: {{ .Values.auth.registrationOpen | quote }} - APP_URL: {{ .Values.auth.appUrl | quote }} - DAILY_SCAN_LIMIT_GLOBAL: {{ .Values.auth.scanLimits.global | default 0 | quote }} - DAILY_SCAN_LIMIT_USER: {{ .Values.auth.scanLimits.user | default 0 | quote }} - SUPERADMIN_VIEW_EMAIL: {{ .Values.auth.superadminView.email | default true | quote }} - SUPERADMIN_VIEW_LAST_LOGIN: {{ .Values.auth.superadminView.lastLogin | default true | quote }} - SUPERADMIN_VIEW_DAILY_SCANS: {{ .Values.auth.superadminView.dailyScans | default true | quote }} - SUPERADMIN_VIEW_SCANNED_PAGES: {{ .Values.auth.superadminView.scannedPages | default true | quote }} - DEMO_SCAN_URL: {{ .Values.auth.demoScanUrl | default "https://example.com" | quote }} - {{- if .Values.auth.firebase.projectId }} - FIREBASE_PROJECT_ID: {{ .Values.auth.firebase.projectId | quote }} - {{- end }} - {{- end }} - {{- if .Values.scanner.enabled }} - SCHEDULE_ENABLED: "true" - SCHEDULE_CRON: {{ .Values.scanner.schedule | quote }} - SCHEDULE_URLS: {{ join "," .Values.scanner.urls | quote }} - {{- end }} - {{- if .Values.notifications.telegram.enabled }} - TELEGRAM_CHAT_ID: {{ .Values.notifications.telegram.chatId | quote }} - {{- end }} - {{- if .Values.notifications.email.enabled }} - SMTP_HOST: {{ .Values.notifications.email.smtpHost | quote }} - SMTP_PORT: {{ .Values.notifications.email.smtpPort | quote }} - NOTIFY_EMAIL: {{ .Values.notifications.email.notifyEmail | quote }} - {{- end }} diff --git a/helm/recon-web/templates/cronjob.yaml b/helm/recon-web/templates/cronjob.yaml deleted file mode 100644 index aba7f70..0000000 --- a/helm/recon-web/templates/cronjob.yaml +++ /dev/null @@ -1,29 +0,0 @@ -{{- if .Values.scanner.enabled }} -apiVersion: batch/v1 -kind: CronJob -metadata: - name: {{ .Release.Name }}-scanner - labels: - app.kubernetes.io/name: recon-web - app.kubernetes.io/component: scanner - app.kubernetes.io/instance: {{ .Release.Name }} -spec: - schedule: {{ .Values.scanner.schedule | quote }} - concurrencyPolicy: Forbid - jobTemplate: - spec: - template: - spec: - restartPolicy: Never - containers: - {{- range .Values.scanner.urls }} - - name: scan-{{ . | replace "." "-" | replace ":" "-" | replace "/" "-" | trunc 50 }} - image: "{{ $.Values.scanner.image.repository }}:{{ $.Values.scanner.image.tag }}" - args: ["scan", {{ . | quote }}, "--json"] - envFrom: - - configMapRef: - name: {{ $.Release.Name }}-config - - secretRef: - name: {{ $.Release.Name }}-secret - {{- end }} -{{- end }} diff --git a/helm/recon-web/templates/ingress.yaml b/helm/recon-web/templates/ingress.yaml deleted file mode 100644 index fdce3eb..0000000 --- a/helm/recon-web/templates/ingress.yaml +++ /dev/null @@ -1,48 +0,0 @@ -{{- if .Values.ingress.enabled }} -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: {{ .Release.Name }}-ingress - labels: - app.kubernetes.io/name: recon-web - app.kubernetes.io/instance: {{ .Release.Name }} - {{- with .Values.ingress.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - {{- if .Values.ingress.className }} - ingressClassName: {{ .Values.ingress.className | quote }} - {{- end }} - {{- if .Values.ingress.tls }} - tls: - - hosts: - - {{ .Values.ingress.host | quote }} - secretName: {{ .Release.Name }}-tls - {{- end }} - rules: - - host: {{ .Values.ingress.host | quote }} - http: - paths: - - path: /api - pathType: Prefix - backend: - service: - name: {{ .Release.Name }}-api - port: - number: 3000 - - path: /health - pathType: Exact - backend: - service: - name: {{ .Release.Name }}-api - port: - number: 3000 - - path: / - pathType: Prefix - backend: - service: - name: {{ .Release.Name }}-web - port: - number: 80 -{{- end }} diff --git a/helm/recon-web/templates/pvc.yaml b/helm/recon-web/templates/pvc.yaml deleted file mode 100644 index 8799cd8..0000000 --- a/helm/recon-web/templates/pvc.yaml +++ /dev/null @@ -1,18 +0,0 @@ -{{- if .Values.persistence.enabled }} -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: {{ .Release.Name }}-data - labels: - app.kubernetes.io/name: recon-web - app.kubernetes.io/instance: {{ .Release.Name }} -spec: - accessModes: - - {{ .Values.persistence.accessMode }} - {{- if .Values.persistence.storageClass }} - storageClassName: {{ .Values.persistence.storageClass | quote }} - {{- end }} - resources: - requests: - storage: {{ .Values.persistence.size }} -{{- end }} diff --git a/helm/recon-web/templates/secret.yaml b/helm/recon-web/templates/secret.yaml deleted file mode 100644 index 3b072fa..0000000 --- a/helm/recon-web/templates/secret.yaml +++ /dev/null @@ -1,28 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: {{ .Release.Name }}-secret - labels: - app.kubernetes.io/name: recon-web - app.kubernetes.io/instance: {{ .Release.Name }} -type: Opaque -stringData: - {{- if .Values.auth.jwtSecret }} - JWT_SECRET: {{ .Values.auth.jwtSecret | quote }} - {{- end }} - {{- if .Values.auth.setupToken }} - SETUP_TOKEN: {{ .Values.auth.setupToken | quote }} - {{- end }} - {{- if .Values.auth.firebase.apiKey }} - FIREBASE_API_KEY: {{ .Values.auth.firebase.apiKey | quote }} - {{- end }} - {{- range $key, $value := .Values.api.env }} - {{ $key }}: {{ $value | quote }} - {{- end }} - {{- if .Values.notifications.telegram.botToken }} - TELEGRAM_BOT_TOKEN: {{ .Values.notifications.telegram.botToken | quote }} - {{- end }} - {{- if .Values.notifications.email.smtpUser }} - SMTP_USER: {{ .Values.notifications.email.smtpUser | quote }} - SMTP_PASS: {{ .Values.notifications.email.smtpPass | quote }} - {{- end }} diff --git a/helm/recon-web/templates/web-deployment.yaml b/helm/recon-web/templates/web-deployment.yaml deleted file mode 100644 index 5c592db..0000000 --- a/helm/recon-web/templates/web-deployment.yaml +++ /dev/null @@ -1,40 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ .Release.Name }}-web - labels: - app.kubernetes.io/name: recon-web - app.kubernetes.io/component: web - app.kubernetes.io/instance: {{ .Release.Name }} -spec: - replicas: {{ .Values.web.replicas }} - selector: - matchLabels: - app.kubernetes.io/name: recon-web - app.kubernetes.io/component: web - app.kubernetes.io/instance: {{ .Release.Name }} - template: - metadata: - labels: - app.kubernetes.io/name: recon-web - app.kubernetes.io/component: web - app.kubernetes.io/instance: {{ .Release.Name }} - spec: - containers: - - name: web - image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}" - imagePullPolicy: {{ .Values.web.image.pullPolicy }} - ports: - - containerPort: 80 - protocol: TCP - env: - - name: API_UPSTREAM - value: "http://{{ .Release.Name }}-api:3000" - livenessProbe: - httpGet: - path: / - port: 80 - initialDelaySeconds: 5 - periodSeconds: 30 - resources: - {{- toYaml .Values.web.resources | nindent 12 }} diff --git a/helm/recon-web/templates/web-service.yaml b/helm/recon-web/templates/web-service.yaml deleted file mode 100644 index addee2e..0000000 --- a/helm/recon-web/templates/web-service.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ .Release.Name }}-web - labels: - app.kubernetes.io/name: recon-web - app.kubernetes.io/component: web - app.kubernetes.io/instance: {{ .Release.Name }} -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - selector: - app.kubernetes.io/name: recon-web - app.kubernetes.io/component: web - app.kubernetes.io/instance: {{ .Release.Name }} diff --git a/helm/recon-web/values.yaml b/helm/recon-web/values.yaml deleted file mode 100644 index 87df394..0000000 --- a/helm/recon-web/values.yaml +++ /dev/null @@ -1,94 +0,0 @@ -api: - image: - repository: ghcr.io/brunoafk/recon-web/api - tag: latest - pullPolicy: IfNotPresent - replicas: 1 - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - cpu: 1000m - memory: 1Gi - env: {} - # GOOGLE_CLOUD_API_KEY: "" - # MAX_CONCURRENCY: "8" - # MAX_CONCURRENT_SCANS: "3" - -web: - image: - repository: ghcr.io/brunoafk/recon-web/web - tag: latest - pullPolicy: IfNotPresent - replicas: 1 - resources: - requests: - cpu: 50m - memory: 64Mi - limits: - cpu: 200m - memory: 128Mi - -ingress: - enabled: false - className: "" - host: recon.example.com - tls: false - annotations: {} - -scanner: - enabled: false - schedule: "0 0 * * *" - urls: [] - image: - repository: ghcr.io/brunoafk/recon-web/cli - tag: latest - -notifications: - telegram: - enabled: false - botToken: "" - chatId: "" - email: - enabled: false - smtpHost: "" - smtpPort: "587" - smtpUser: "" - smtpPass: "" - notifyEmail: "" - -auth: - # Provider: "local", "firebase", or "" (disabled) - provider: "" - # Required for local auth (min 32 chars) - jwtSecret: "" - # One-time token for creating first superadmin via API - setupToken: "" - registrationOpen: true - appUrl: "" - # Firebase (only when provider=firebase) - firebase: - projectId: "" - apiKey: "" - serviceAccountPath: "" - # Daily scan limits (0 = unlimited) - scanLimits: - global: 0 - user: 0 - # Max concurrent scans server-wide - maxConcurrentScans: 3 - # Superadmin panel column visibility - superadminView: - email: true - lastLogin: true - dailyScans: true - scannedPages: true - # Demo scan URL (shown to unauthenticated users) - demoScanUrl: "https://example.com" - -persistence: - enabled: true - size: 1Gi - storageClass: "" - accessMode: ReadWriteOnce diff --git a/package-lock.json b/package-lock.json index aae5e98..6abc1dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "recon-web", - "version": "1.1.1", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "recon-web", - "version": "1.1.1", + "version": "1.2.0", "license": "GPL-2.0-only", "workspaces": [ "packages/*" @@ -19,59 +19,37 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.1.tgz", - "integrity": "sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.9.tgz", + "integrity": "sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==", "dev": true, "license": "MIT", "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.7" + "@csstools/css-tokenizer": "^4.0.0" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", - "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.9.tgz", + "integrity": "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==", "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7" + "is-potential-custom-element-name": "^1.0.1" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", @@ -697,9 +675,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -714,9 +692,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -731,9 +709,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -748,9 +726,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -765,9 +743,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -782,9 +760,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -799,9 +777,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -816,9 +794,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -833,9 +811,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -850,9 +828,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -867,9 +845,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -884,9 +862,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -901,9 +879,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -918,9 +896,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -935,9 +913,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -952,9 +930,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -969,9 +947,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -986,9 +964,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -1003,9 +981,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -1020,9 +998,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -1037,9 +1015,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -1054,9 +1032,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -1071,9 +1049,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -1088,9 +1066,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -1105,9 +1083,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -1122,9 +1100,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -1428,9 +1406,9 @@ } }, "node_modules/@fastify/swagger-ui/node_modules/@fastify/static": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.0.0.tgz", - "integrity": "sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.0.tgz", + "integrity": "sha512-EPRNQYqEYEYTK8yyGbcM0iHpyJaupb94bey5O6iCQfLTADr02kaZU+qeHSdd9H9TiMwTBVkrMa59V8CMbn3avQ==", "funding": [ { "type": "github", @@ -1452,9 +1430,9 @@ } }, "node_modules/@fastify/swagger-ui/node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", "engines": { "node": ">=18" @@ -1569,6 +1547,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1586,6 +1567,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1603,6 +1587,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1620,6 +1607,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1637,6 +1627,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1654,6 +1647,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1671,6 +1667,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1694,6 +1693,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1717,6 +1719,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1740,6 +1745,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1763,6 +1771,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1786,6 +1797,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2290,6 +2304,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2304,6 +2321,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2318,6 +2338,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2332,6 +2355,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2346,6 +2372,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2360,6 +2389,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2374,6 +2406,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2388,6 +2423,9 @@ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2402,6 +2440,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2416,6 +2457,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2430,6 +2474,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2444,6 +2491,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2458,6 +2508,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2699,6 +2752,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2716,6 +2772,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2733,6 +2792,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2750,6 +2812,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2839,9 +2904,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.96.1", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.1.tgz", - "integrity": "sha512-u1yBgtavSy+N8wgtW3PiER6UpxcplMje65yXnnVgiHTqiMwLlxiw4WvQDrXyn+UD6lnn8kHaxmerJUzQcV/MMg==", + "version": "5.97.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.97.0.tgz", + "integrity": "sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg==", "license": "MIT", "funding": { "type": "github", @@ -2849,12 +2914,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.96.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.1.tgz", - "integrity": "sha512-2X7KYK5KKWUKGeWCVcqxXAkYefJtrKB7tSKWgeG++b0H6BRHxQaLSSi8AxcgjmUnnosHuh9WsFZqvE16P1WCzA==", + "version": "5.97.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.97.0.tgz", + "integrity": "sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.96.1" + "@tanstack/query-core": "5.97.0" }, "funding": { "type": "github", @@ -3083,9 +3148,9 @@ } }, "node_modules/@types/node": { - "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3495,9 +3560,9 @@ } }, "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -3529,9 +3594,9 @@ } }, "node_modules/bare-fs": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.6.0.tgz", - "integrity": "sha512-2YkS7NuiJceSEbyEOdSNLE9tsGd+f4+f7C+Nik/MCk27SYdwIMPT/yRKvg++FZhQXgk0KWJKJyXX9RhVV0RGqA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.0.tgz", + "integrity": "sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA==", "license": "Apache-2.0", "dependencies": { "bare-events": "^2.5.4", @@ -3571,9 +3636,9 @@ } }, "node_modules/bare-stream": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.12.0.tgz", - "integrity": "sha512-w28i8lkBgREV3rPXGbgK+BO66q+ZpKqRWrZLiCdmmUlLPrQ45CzkvRhN+7lnv00Gpi2zy5naRxnUFAxCECDm9g==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", + "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", "license": "Apache-2.0", "dependencies": { "streamx": "^2.25.0", @@ -3626,9 +3691,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.13", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", - "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "version": "2.10.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", + "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3639,9 +3704,9 @@ } }, "node_modules/basic-ftp": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.1.tgz", - "integrity": "sha512-0yaL8JdxTknKDILitVpfYfV2Ob6yb3udX/hK97M7I3jOeznBNxQPtVvTUtnhUkyHlxFWyr5Lvknmgzoc7jf+1Q==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.2.tgz", + "integrity": "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3700,20 +3765,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -3875,9 +3926,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001784", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", - "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", "dev": true, "funding": [ { @@ -3995,15 +4046,6 @@ "devtools-protocol": "*" } }, - "node_modules/chromium-bidi/node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -4428,11 +4470,13 @@ } }, "node_modules/data-uri-to-buffer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", - "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", - "dev": true, - "license": "MIT" + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } }, "node_modules/data-urls": { "version": "7.0.0", @@ -4523,9 +4567,9 @@ } }, "node_modules/defu": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz", - "integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "dev": true, "license": "MIT" }, @@ -4693,10 +4737,40 @@ "readable-stream": "^2.0.2" } }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/electron-to-chromium": { - "version": "1.5.330", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.330.tgz", - "integrity": "sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA==", + "version": "1.5.335", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", + "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==", "dev": true, "license": "ISC" }, @@ -4807,9 +4881,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4820,32 +4894,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escalade": { @@ -5386,6 +5460,13 @@ "source-map": "^0.6.1" } }, + "node_modules/get-source/node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, "node_modules/get-stream": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", @@ -5429,15 +5510,6 @@ "node": ">= 14" } }, - "node_modules/get-uri/node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -5882,14 +5954,14 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "29.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", - "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@asamuzakjp/dom-selector": "^7.0.3", + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", @@ -5936,9 +6008,9 @@ } }, "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -6243,6 +6315,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6264,6 +6339,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6285,6 +6363,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6306,6 +6387,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6578,6 +6662,38 @@ "node": ">=14.0" } }, + "node_modules/miniflare/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/miniflare/node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -6639,9 +6755,9 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.12.14", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.14.tgz", - "integrity": "sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==", + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.2.tgz", + "integrity": "sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -6745,9 +6861,9 @@ "license": "MIT" }, "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", "license": "MIT", "engines": { "node": ">= 0.4.0" @@ -6793,16 +6909,16 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "dev": true, "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", - "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -7060,9 +7176,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -7174,9 +7290,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", "dev": true, "funding": [ { @@ -7546,26 +7662,19 @@ } }, "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" } }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -8282,20 +8391,14 @@ "license": "MIT" }, "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", "dependencies": { - "safe-buffer": "~5.1.0" + "safe-buffer": "~5.2.0" } }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -8418,20 +8521,6 @@ "node": ">=6" } }, - "node_modules/tar-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", @@ -8533,22 +8622,22 @@ } }, "node_modules/tldts": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", - "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.27" + "tldts-core": "^7.0.28" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", - "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", "dev": true, "license": "MIT" }, @@ -10066,9 +10155,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -10273,10 +10362,9 @@ } }, "node_modules/zod": { - "version": "3.22.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", - "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", - "dev": true, + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -10284,7 +10372,7 @@ }, "packages/api": { "name": "@recon-web/api", - "version": "1.1.1", + "version": "1.2.0", "dependencies": { "@fastify/cors": "^10.0.0", "@fastify/rate-limit": "^10.1.0", @@ -10312,7 +10400,7 @@ }, "packages/cli": { "name": "@recon-web/cli", - "version": "1.1.1", + "version": "1.2.0", "dependencies": { "@recon-web/core": "*", "bcryptjs": "^3.0.3", @@ -10334,9 +10422,9 @@ }, "packages/core": { "name": "@recon-web/core", - "version": "1.1.1", + "version": "1.2.0", "dependencies": { - "axios": "^1.7.0", + "axios": "^1.15.0", "cheerio": "^1.0.0", "csv-parser": "^3.0.0", "got": "^14.4.0", @@ -10367,14 +10455,14 @@ }, "packages/static": { "name": "@recon-web/static", - "version": "1.1.1", + "version": "1.2.0", "devDependencies": { "wrangler": "^3.0.0" } }, "packages/web": { "name": "@recon-web/web", - "version": "1.1.1", + "version": "1.2.0", "dependencies": { "@tanstack/react-query": "^5.59.0", "leaflet": "^1.9.4", diff --git a/package.json b/package.json index b7f8d04..af50045 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "recon-web", "private": true, - "version": "1.1.1", + "version": "1.2.0", "license": "GPL-2.0-only", "type": "module", "workspaces": [ @@ -20,6 +20,9 @@ "build:static": "npm run build -w @recon-web/web && npm run build -w @recon-web/static", "prepare": "husky || true" }, + "overrides": { + "basic-ftp": "^5.2.2" + }, "devDependencies": { "husky": "^9.1.7" } diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index fb051df..df38ea1 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -40,6 +40,12 @@ RUN apt-get update && \ libgbm1 \ && rm -rf /var/lib/apt/lists/* +# Ensure Chromium namespace sandbox has correct permissions when present. +RUN if [ -f /usr/lib/chromium/chrome-sandbox ]; then \ + chown root:root /usr/lib/chromium/chrome-sandbox && \ + chmod 4755 /usr/lib/chromium/chrome-sandbox; \ + fi + WORKDIR /app COPY package.json package-lock.json ./ @@ -69,4 +75,10 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD node -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" +# Non-root runtime user +RUN groupadd -r app -g 10001 && useradd -r -g app -u 10001 -m -d /home/app app && \ + mkdir -p /app/data && \ + chown -R 10001:10001 /app /home/app +USER 10001:10001 + CMD ["node", "packages/api/dist/index.js"] diff --git a/packages/api/package.json b/packages/api/package.json index 09062c7..8a8b24e 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@recon-web/api", - "version": "1.1.1", + "version": "1.2.0", "type": "module", "main": "dist/index.js", "exports": { diff --git a/packages/api/src/config.ts b/packages/api/src/config.ts index 5ac4302..9240319 100644 --- a/packages/api/src/config.ts +++ b/packages/api/src/config.ts @@ -30,7 +30,9 @@ export const config = { port: parseInt(process.env.PORT || '3000', 10), host: process.env.HOST || '0.0.0.0', timeoutLimit: parseInt(process.env.API_TIMEOUT_LIMIT || '30000', 10), - corsOrigin: process.env.API_CORS_ORIGIN || '*', + corsOrigin: process.env.API_CORS_ORIGIN + ? process.env.API_CORS_ORIGIN.split(',').map((s) => s.trim()) + : false, chromePath: detectChromePath(), staticDir: process.env.STATIC_DIR || undefined, maxConcurrency: parseInt(process.env.MAX_CONCURRENCY || '8', 10), diff --git a/packages/api/src/server.test.ts b/packages/api/src/server.test.ts index cfc376f..b28a4b0 100644 --- a/packages/api/src/server.test.ts +++ b/packages/api/src/server.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { randomUUID } from 'node:crypto'; import { unlinkSync } from 'node:fs'; import { buildServer } from './server.js'; @@ -21,6 +21,67 @@ afterAll(async () => { delete process.env.DB_PATH; }); +describe('server — CORS hardening', () => { + let app: Awaited>; + let corsTestDbPath: string; + + beforeEach(async () => { + delete process.env.API_CORS_ORIGIN; + corsTestDbPath = `/tmp/recon-web-cors-test-${randomUUID()}.db`; + process.env.DB_PATH = corsTestDbPath; + app = await buildServer(); + }); + + afterEach(async () => { + await app.close(); + try { unlinkSync(corsTestDbPath); } catch {} + try { unlinkSync(corsTestDbPath + '-wal'); } catch {} + try { unlinkSync(corsTestDbPath + '-shm'); } catch {} + delete process.env.DB_PATH; + }); + + it('does not return Access-Control-Allow-Origin: * by default', async () => { + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { origin: 'http://evil.example.com' }, + }); + expect(res.headers['access-control-allow-origin']).not.toBe('*'); + }); +}); + +describe('server — rate limiting', () => { + let app: Awaited>; + let rateLimitTestDbPath: string; + + beforeEach(async () => { + process.env.RATE_LIMIT_MAX = '5'; + process.env.RATE_LIMIT_WINDOW = '1 minute'; + rateLimitTestDbPath = `/tmp/recon-web-ratelimit-test-${randomUUID()}.db`; + process.env.DB_PATH = rateLimitTestDbPath; + app = await buildServer(); + }); + + afterEach(async () => { + await app.close(); + try { unlinkSync(rateLimitTestDbPath); } catch {} + try { unlinkSync(rateLimitTestDbPath + '-wal'); } catch {} + try { unlinkSync(rateLimitTestDbPath + '-shm'); } catch {} + delete process.env.RATE_LIMIT_MAX; + delete process.env.RATE_LIMIT_WINDOW; + delete process.env.DB_PATH; + }); + + it('returns 429 after burst exceeds the limit', async () => { + const responses = []; + for (let i = 0; i < 8; i++) { + responses.push(await app.inject({ method: 'GET', url: '/health' })); + } + const status = responses.map((r) => r.statusCode); + expect(status.filter((s) => s === 429).length).toBeGreaterThan(0); + }); +}); + describe('API', () => { describe('GET /health', () => { it('returns 200 with status ok, handlers count, and uptime', async () => { diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index c7b344f..1e5e9e3 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -42,8 +42,16 @@ export async function buildServer(opts?: BuildServerOptions) { }); await app.register(rateLimit, { - max: parseInt(process.env.RATE_LIMIT_MAX || '', 10) || 100, - timeWindow: process.env.RATE_LIMIT_WINDOW || '10 minutes', + global: true, + max: parseInt(process.env.RATE_LIMIT_MAX || '', 10) || 200, + timeWindow: process.env.RATE_LIMIT_WINDOW || '1 minute', + keyGenerator: (req) => req.ip, + addHeaders: { + 'x-ratelimit-limit': true, + 'x-ratelimit-remaining': true, + 'x-ratelimit-reset': true, + 'retry-after': true, + }, }); if (process.env.SWAGGER_ENABLED === 'true') { diff --git a/packages/cli/Dockerfile b/packages/cli/Dockerfile index 0db71d7..a296866 100644 --- a/packages/cli/Dockerfile +++ b/packages/cli/Dockerfile @@ -45,4 +45,8 @@ COPY --from=build /app/packages/cli/dist packages/cli/dist ENV NODE_ENV=production +RUN addgroup -g 10001 app && adduser -D -G app -u 10001 app && \ + mkdir -p /app/data && chown -R 10001:10001 /app +USER 10001:10001 + ENTRYPOINT ["node", "packages/cli/dist/index.js"] diff --git a/packages/cli/package.json b/packages/cli/package.json index d10b22e..dbf0919 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@recon-web/cli", - "version": "1.1.1", + "version": "1.2.0", "type": "module", "bin": { "recon-web": "dist/index.js" diff --git a/packages/core/package.json b/packages/core/package.json index ea73f86..5b2b58d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@recon-web/core", - "version": "1.1.1", + "version": "1.2.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -17,7 +17,7 @@ "test:watch": "vitest" }, "dependencies": { - "axios": "^1.7.0", + "axios": "^1.15.0", "cheerio": "^1.0.0", "csv-parser": "^3.0.0", "got": "^14.4.0", diff --git a/packages/core/src/handlers/archives.ts b/packages/core/src/handlers/archives.ts index 95a8c83..819f972 100644 --- a/packages/core/src/handlers/archives.ts +++ b/packages/core/src/handlers/archives.ts @@ -1,5 +1,6 @@ -import axios from 'axios'; import type { AnalysisHandler, HandlerResult } from '../types.js'; +import { safeFetch } from '../utils/safe-fetch.js'; +import { SsrfBlockedError } from '../utils/network.js'; interface ScanFrequency { daysBetweenScans: number; @@ -72,7 +73,8 @@ export const archivesHandler: AnalysisHandler = async (url, opti const cdxUrl = `https://web.archive.org/cdx/search/cdx?url=${url}&output=json&fl=timestamp,statuscode,digest,length,offset`; try { - const { data } = await axios.get(cdxUrl, { timeout: options?.timeout }); + const response = await safeFetch(cdxUrl, { timeoutMs: options?.timeout }); + const data = response.data; if (!data || !Array.isArray(data) || data.length <= 1) { return { @@ -101,6 +103,9 @@ export const archivesHandler: AnalysisHandler = async (url, opti }, }; } catch (error) { + if (error instanceof SsrfBlockedError) { + return { error: 'Blocked: target resolves to private address' }; + } return { error: `Error fetching Wayback data: ${(error as Error).message}` }; } }; diff --git a/packages/core/src/handlers/cookies.ts b/packages/core/src/handlers/cookies.ts index 100533c..3932af3 100644 --- a/packages/core/src/handlers/cookies.ts +++ b/packages/core/src/handlers/cookies.ts @@ -1,7 +1,7 @@ -import axios from 'axios'; import type { AnalysisHandler, HandlerResult } from '../types.js'; -import { getFinalResponseUrl } from '../utils/http.js'; import { normalizeUrl } from '../utils/url.js'; +import { safeFetch } from '../utils/safe-fetch.js'; +import { SsrfBlockedError } from '../utils/network.js'; interface CookieEntry { name: string; @@ -25,25 +25,18 @@ export interface CookiesResult { export const cookiesHandler: AnalysisHandler = async (url, options) => { const targetUrl = normalizeUrl(url); let headerCookies: string[] | null = null; - let finalUrl: string | undefined; try { - const response = await axios.get(targetUrl, { - withCredentials: true, + const response = await safeFetch(targetUrl, { + timeoutMs: options?.timeout, maxRedirects: 5, - timeout: options?.timeout, }); headerCookies = (response.headers['set-cookie'] as string[] | undefined) ?? null; - finalUrl = getFinalResponseUrl(response) ?? targetUrl; } catch (error) { - const axiosError = error as any; - if (axiosError.response) { - return { error: `Request failed with status ${axiosError.response.status}: ${axiosError.message}` }; - } else if (axiosError.request) { - return { error: `No response received: ${axiosError.message}` }; - } else { - return { error: `Error setting up request: ${(error as Error).message}` }; + if (error instanceof SsrfBlockedError) { + return { error: 'Blocked: target resolves to private address' }; } + return { error: `Error setting up request: ${(error as Error).message}` }; } // Client-side cookies require puppeteer, which is not available in core. @@ -51,8 +44,8 @@ export const cookiesHandler: AnalysisHandler = async (url, option const clientCookies: CookieEntry[] | null = null as CookieEntry[] | null; if (!headerCookies && (!clientCookies || clientCookies.length === 0)) { - return { data: { headerCookies, clientCookies, finalUrl, message: 'No cookies were detected in the server response.' } }; + return { data: { headerCookies, clientCookies, finalUrl: targetUrl, message: 'No cookies were detected in the server response.' } }; } - return { data: { headerCookies, clientCookies, finalUrl } }; + return { data: { headerCookies, clientCookies, finalUrl: targetUrl } }; }; diff --git a/packages/core/src/handlers/firewall.test.ts b/packages/core/src/handlers/firewall.test.ts index 3e30e68..3e01e5b 100644 --- a/packages/core/src/handlers/firewall.test.ts +++ b/packages/core/src/handlers/firewall.test.ts @@ -1,14 +1,24 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { firewallHandler } from './firewall.js'; +import * as dns from 'node:dns/promises'; + +vi.mock('node:dns/promises'); const server = setupServer(); beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); -afterEach(() => server.resetHandlers()); +afterEach(() => { + server.resetHandlers(); + vi.restoreAllMocks(); +}); afterAll(() => server.close()); +beforeEach(() => { + vi.mocked(dns.lookup).mockResolvedValue({ address: '93.184.216.34', family: 4 }); +}); + describe('firewallHandler', () => { it('detects Cloudflare WAF from server header', async () => { server.use( @@ -86,3 +96,12 @@ describe('firewallHandler', () => { expect(typeof result.error).toBe('string'); }); }); + +describe('firewall handler — SSRF', () => { + it('refuses to fetch a hostname that resolves to AWS metadata IP', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValueOnce({ address: '169.254.169.254', family: 4 }); + const result = await firewallHandler('http://metadata.example.com/'); + expect(result.error).toMatch(/private address|Blocked/i); + expect(result.data).toBeUndefined(); + }); +}); diff --git a/packages/core/src/handlers/firewall.ts b/packages/core/src/handlers/firewall.ts index e0fefd5..69e275b 100644 --- a/packages/core/src/handlers/firewall.ts +++ b/packages/core/src/handlers/firewall.ts @@ -1,7 +1,7 @@ -import axios from 'axios'; import type { AnalysisHandler, HandlerResult } from '../types.js'; -import { getFinalResponseUrl } from '../utils/http.js'; import { normalizeUrl } from '../utils/url.js'; +import { safeFetch } from '../utils/safe-fetch.js'; +import { SsrfBlockedError } from '../utils/network.js'; export interface FirewallResult { hasWaf: boolean; @@ -41,10 +41,13 @@ const detectWaf = (headers: Record): FirewallResult => { export const firewallHandler: AnalysisHandler = async (url, options) => { try { const fullUrl = normalizeUrl(url); - const response = await axios.get(fullUrl); + const response = await safeFetch(fullUrl, { timeoutMs: options?.timeout }); const result = detectWaf(response.headers as Record); - return { data: { ...result, finalUrl: getFinalResponseUrl(response) ?? fullUrl } }; + return { data: { ...result, finalUrl: fullUrl } }; } catch (error) { + if (error instanceof SsrfBlockedError) { + return { error: 'Blocked: target resolves to private address' }; + } return { error: (error as Error).message }; } }; diff --git a/packages/core/src/handlers/headers.test.ts b/packages/core/src/handlers/headers.test.ts index 361c464..fa1b352 100644 --- a/packages/core/src/handlers/headers.test.ts +++ b/packages/core/src/handlers/headers.test.ts @@ -1,14 +1,25 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { headersHandler } from './headers.js'; +import * as dns from 'node:dns/promises'; + +vi.mock('node:dns/promises'); const server = setupServer(); beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); -afterEach(() => server.resetHandlers()); +afterEach(() => { + server.resetHandlers(); + vi.restoreAllMocks(); +}); afterAll(() => server.close()); +// Default: dns.lookup resolves to a public IP so existing MSW tests pass through safeFetch +beforeEach(() => { + vi.mocked(dns.lookup).mockResolvedValue({ address: '93.184.216.34', family: 4 }); +}); + describe('headersHandler', () => { it('returns headers from a successful HTTP response', async () => { server.use( @@ -61,3 +72,12 @@ describe('headersHandler', () => { expect(typeof result.error).toBe('string'); }); }); + +describe('headers handler — SSRF', () => { + it('refuses to fetch a hostname that resolves to AWS metadata IP', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValueOnce({ address: '169.254.169.254', family: 4 }); + const result = await headersHandler('http://metadata.example.com/', {}); + expect(result.error).toMatch(/private address|Blocked/i); + expect(result.data).toBeUndefined(); + }); +}); diff --git a/packages/core/src/handlers/headers.ts b/packages/core/src/handlers/headers.ts index 96b5202..ee2d475 100644 --- a/packages/core/src/handlers/headers.ts +++ b/packages/core/src/handlers/headers.ts @@ -1,7 +1,7 @@ -import axios from 'axios'; import type { AnalysisHandler, HandlerResult } from '../types.js'; -import { getFinalResponseUrl } from '../utils/http.js'; import { normalizeUrl } from '../utils/url.js'; +import { safeFetch } from '../utils/safe-fetch.js'; +import { SsrfBlockedError } from '../utils/network.js'; export interface HeadersResult { finalUrl?: string; @@ -11,18 +11,20 @@ export interface HeadersResult { export const headersHandler: AnalysisHandler = async (url, options) => { try { const targetUrl = normalizeUrl(url); - const response = await axios.get(targetUrl, { - timeout: options?.timeout, - validateStatus: (status: number) => status >= 200 && status < 600, - }); + const response = await safeFetch(targetUrl, { timeoutMs: options?.timeout }); return { data: { ...(response.headers as HeadersResult), - finalUrl: getFinalResponseUrl(response) ?? targetUrl, + // Note: finalUrl is the initial (normalized) URL; post-redirect URL is not tracked + // because safeFetch handles redirects internally without exposing the final URL. + finalUrl: targetUrl, }, }; } catch (error) { + if (error instanceof SsrfBlockedError) { + return { error: 'Blocked: target resolves to private address' }; + } return { error: (error as Error).message }; } }; diff --git a/packages/core/src/handlers/http-security.test.ts b/packages/core/src/handlers/http-security.test.ts index 64768f2..670af36 100644 --- a/packages/core/src/handlers/http-security.test.ts +++ b/packages/core/src/handlers/http-security.test.ts @@ -1,14 +1,24 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { httpSecurityHandler } from './http-security.js'; +import * as dns from 'node:dns/promises'; + +vi.mock('node:dns/promises'); const server = setupServer(); beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); -afterEach(() => server.resetHandlers()); +afterEach(() => { + server.resetHandlers(); + vi.restoreAllMocks(); +}); afterAll(() => server.close()); +beforeEach(() => { + vi.mocked(dns.lookup).mockResolvedValue({ address: '93.184.216.34', family: 4 }); +}); + describe('httpSecurityHandler', () => { it('reports all security headers present when they exist', async () => { server.use( @@ -98,3 +108,12 @@ describe('httpSecurityHandler', () => { expect(typeof result.error).toBe('string'); }); }); + +describe('http-security handler — SSRF', () => { + it('refuses to fetch a hostname that resolves to AWS metadata IP', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValueOnce({ address: '169.254.169.254', family: 4 }); + const result = await httpSecurityHandler('http://metadata.example.com/'); + expect(result.error).toMatch(/private address|Blocked/i); + expect(result.data).toBeUndefined(); + }); +}); diff --git a/packages/core/src/handlers/http-security.ts b/packages/core/src/handlers/http-security.ts index eadd0a1..d66e3a7 100644 --- a/packages/core/src/handlers/http-security.ts +++ b/packages/core/src/handlers/http-security.ts @@ -1,7 +1,7 @@ -import axios from 'axios'; import type { AnalysisHandler, HandlerResult } from '../types.js'; -import { getFinalResponseUrl } from '../utils/http.js'; import { normalizeUrl } from '../utils/url.js'; +import { safeFetch } from '../utils/safe-fetch.js'; +import { SsrfBlockedError } from '../utils/network.js'; export interface HttpSecurityResult { finalUrl?: string; @@ -15,11 +15,12 @@ export interface HttpSecurityResult { export const httpSecurityHandler: AnalysisHandler = async (url, options) => { try { const fullUrl = normalizeUrl(url); - const response = await axios.get(fullUrl); + const response = await safeFetch(fullUrl, { timeoutMs: options?.timeout }); const headers = response.headers; const result: HttpSecurityResult = { - finalUrl: getFinalResponseUrl(response) ?? fullUrl, + // Note: finalUrl is the initial (normalized) URL; post-redirect URL is not tracked + finalUrl: fullUrl, strictTransportPolicy: !!headers['strict-transport-security'], xFrameOptions: !!headers['x-frame-options'], xContentTypeOptions: !!headers['x-content-type-options'], @@ -29,6 +30,9 @@ export const httpSecurityHandler: AnalysisHandler = async (u return { data: result }; } catch (error) { + if (error instanceof SsrfBlockedError) { + return { error: 'Blocked: target resolves to private address' }; + } return { error: (error as Error).message }; } }; diff --git a/packages/core/src/handlers/legacy-rank.ts b/packages/core/src/handlers/legacy-rank.ts index 9eae46d..67526d4 100644 --- a/packages/core/src/handlers/legacy-rank.ts +++ b/packages/core/src/handlers/legacy-rank.ts @@ -4,6 +4,7 @@ import csv from 'csv-parser'; import fs from 'fs'; import type { AnalysisHandler, HandlerResult } from '../types.js'; import { normalizeUrl } from '../utils/url.js'; +import { assertPublicHost, SsrfBlockedError } from '../utils/network.js'; const FILE_URL = 'https://s3-us-west-1.amazonaws.com/umbrella-static/top-1m.csv.zip'; const TEMP_FILE_PATH = '/tmp/top-1m.csv'; @@ -27,6 +28,10 @@ export const legacyRankHandler: AnalysisHandler = async (url, try { // Download and unzip the file if not in cache if (!fs.existsSync(TEMP_FILE_PATH)) { + // Validate that FILE_URL resolves to a public host before downloading + const fileUrlParsed = new URL(FILE_URL); + await assertPublicHost(fileUrlParsed.hostname); + const response = await axios({ method: 'GET', url: FILE_URL, @@ -72,6 +77,9 @@ export const legacyRankHandler: AnalysisHandler = async (url, .on('error', reject); }); } catch (error) { + if (error instanceof SsrfBlockedError) { + return { error: 'Blocked: target resolves to private address' }; + } return { error: (error as Error).message }; } }; diff --git a/packages/core/src/handlers/linked-pages.ts b/packages/core/src/handlers/linked-pages.ts index ca491df..a1aef01 100644 --- a/packages/core/src/handlers/linked-pages.ts +++ b/packages/core/src/handlers/linked-pages.ts @@ -1,8 +1,8 @@ -import axios from 'axios'; import * as cheerio from 'cheerio'; import type { AnalysisHandler, HandlerResult } from '../types.js'; import { normalizeUrl } from '../utils/url.js'; -import { getFinalResponseUrl } from '../utils/http.js'; +import { safeFetch } from '../utils/safe-fetch.js'; +import { SsrfBlockedError } from '../utils/network.js'; export interface LinkedPagesResult { internal: string[]; @@ -14,8 +14,9 @@ export interface LinkedPagesResult { export const linkedPagesHandler: AnalysisHandler = async (url, options) => { try { const targetUrl = normalizeUrl(url); - const response = await axios.get(targetUrl, { timeout: options?.timeout }); - const finalUrl = getFinalResponseUrl(response) ?? targetUrl; + const response = await safeFetch(targetUrl, { timeoutMs: options?.timeout }); + // Note: finalUrl is the initial (normalized) URL; post-redirect URL is not tracked + const finalUrl = targetUrl; const finalOrigin = new URL(finalUrl).origin; const html: string = response.data; const $ = cheerio.load(html); @@ -56,6 +57,9 @@ export const linkedPagesHandler: AnalysisHandler = async (url return { data: { internal, external, finalUrl } }; } catch (error) { + if (error instanceof SsrfBlockedError) { + return { error: 'Blocked: target resolves to private address' }; + } return { error: (error as Error).message }; } }; diff --git a/packages/core/src/handlers/quality.test.ts b/packages/core/src/handlers/quality.test.ts index 8fe20a0..bab0212 100644 --- a/packages/core/src/handlers/quality.test.ts +++ b/packages/core/src/handlers/quality.test.ts @@ -1,14 +1,24 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { qualityHandler } from './quality.js'; +import * as dns from 'node:dns/promises'; + +vi.mock('node:dns/promises'); const server = setupServer(); beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); -afterEach(() => server.resetHandlers()); +afterEach(() => { + server.resetHandlers(); + vi.restoreAllMocks(); +}); afterAll(() => server.close()); +beforeEach(() => { + vi.mocked(dns.lookup).mockResolvedValue({ address: '93.184.216.34', family: 4 }); +}); + const TEST_URL = 'https://example.com'; const options = { @@ -85,3 +95,13 @@ describe('qualityHandler', () => { expect(typeof result.error).toBe('string'); }); }); + +describe('quality handler — SSRF', () => { + it('refuses to fetch when the API endpoint resolves to a private address', async () => { + // Mock all dns.lookup calls to return a private IP (affects Google API endpoint lookup) + vi.spyOn(dns, 'lookup').mockResolvedValue({ address: '169.254.169.254', family: 4 }); + const result = await qualityHandler('https://example.com/', options); + expect(result.error).toMatch(/private address|Blocked/i); + expect(result.data).toBeUndefined(); + }); +}); diff --git a/packages/core/src/handlers/quality.ts b/packages/core/src/handlers/quality.ts index f6115e5..45d7558 100644 --- a/packages/core/src/handlers/quality.ts +++ b/packages/core/src/handlers/quality.ts @@ -1,6 +1,7 @@ -import axios from 'axios'; import type { AnalysisHandler, HandlerResult } from '../types.js'; import { withRetry } from '../utils/retry.js'; +import { safeFetch } from '../utils/safe-fetch.js'; +import { SsrfBlockedError } from '../utils/network.js'; export interface QualityResult { [key: string]: unknown; @@ -24,18 +25,21 @@ export const qualityHandler: AnalysisHandler = async (url, option `&key=${apiKey}`; const response = await withRetry( - () => axios.get(endpoint, { timeout: options?.timeout }), + () => safeFetch(endpoint, { timeoutMs: options?.timeout }), ); - return { data: response.data as QualityResult }; - } catch (error) { - const axiosErr = error as any; - if (axiosErr.response) { - const status = axiosErr.response.status; - const detail = axiosErr.response.data?.error; + + if (response.status >= 400) { + const detail = response.data?.error; const msg = detail ? `${detail.message}${detail.status ? ` [${detail.status}]` : ''}${detail.errors?.length ? ` — ${detail.errors.map((e: any) => e.reason).join(', ')}` : ''}` - : JSON.stringify(axiosErr.response.data); - return { error: `PageSpeed API ${status}: ${msg}` }; + : JSON.stringify(response.data); + return { error: `PageSpeed API ${response.status}: ${msg}` }; + } + + return { data: response.data as QualityResult }; + } catch (error) { + if (error instanceof SsrfBlockedError) { + return { error: 'Blocked: target resolves to private address' }; } return { error: (error as Error).message }; } diff --git a/packages/core/src/handlers/rank.test.ts b/packages/core/src/handlers/rank.test.ts index 24cd537..7ed838f 100644 --- a/packages/core/src/handlers/rank.test.ts +++ b/packages/core/src/handlers/rank.test.ts @@ -1,14 +1,24 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { rankHandler } from './rank.js'; +import * as dns from 'node:dns/promises'; + +vi.mock('node:dns/promises'); const server = setupServer(); beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); -afterEach(() => server.resetHandlers()); +afterEach(() => { + server.resetHandlers(); + vi.restoreAllMocks(); +}); afterAll(() => server.close()); +beforeEach(() => { + vi.mocked(dns.lookup).mockResolvedValue({ address: '93.184.216.34', family: 4 }); +}); + const TEST_URL = 'https://example.com'; describe('rankHandler', () => { @@ -80,6 +90,7 @@ describe('rankHandler', () => { }); it('passes authentication config when API key is provided', async () => { + vi.mocked(dns.lookup).mockResolvedValue({ address: '93.184.216.34', family: 4 }); let receivedAuth: string | null = null; server.use( @@ -105,3 +116,12 @@ describe('rankHandler', () => { expect(receivedAuth).toContain('Basic'); }); }); + +describe('rank handler — SSRF', () => { + it('refuses to fetch a hostname that resolves to AWS metadata IP', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValueOnce({ address: '169.254.169.254', family: 4 }); + const result = await rankHandler('http://metadata.example.com/'); + expect(result.error).toMatch(/private address|Blocked/i); + expect(result.data).toBeUndefined(); + }); +}); diff --git a/packages/core/src/handlers/rank.ts b/packages/core/src/handlers/rank.ts index 6a4a33e..5ad4e39 100644 --- a/packages/core/src/handlers/rank.ts +++ b/packages/core/src/handlers/rank.ts @@ -1,6 +1,7 @@ -import axios from 'axios'; import type { AnalysisHandler, HandlerResult } from '../types.js'; import { normalizeUrl } from '../utils/url.js'; +import { safeFetch } from '../utils/safe-fetch.js'; +import { SsrfBlockedError } from '../utils/network.js'; interface RankEntry { date: string; @@ -28,13 +29,16 @@ export const rankHandler: AnalysisHandler = async (url, options) => try { const apiKey = options?.apiKeys?.TRANCO_API_KEY; - const authConfig = apiKey - ? { auth: { username: options?.apiKeys?.TRANCO_USERNAME ?? '', password: apiKey } } - : {}; + const extraHeaders: Record = {}; + if (apiKey) { + const username = options?.apiKeys?.TRANCO_USERNAME ?? ''; + const credentials = Buffer.from(`${username}:${apiKey}`).toString('base64'); + extraHeaders['Authorization'] = `Basic ${credentials}`; + } - const response = await axios.get( + const response = await safeFetch( `https://tranco-list.eu/api/ranks/domain/${domain}`, - { timeout: options?.timeout ?? 5000, ...authConfig }, + { timeoutMs: options?.timeout ?? 5000, headers: extraHeaders }, ); if (!response.data || !response.data.ranks || response.data.ranks.length === 0) { @@ -49,6 +53,9 @@ export const rankHandler: AnalysisHandler = async (url, options) => return { data: response.data as RankResult }; } catch (error) { + if (error instanceof SsrfBlockedError) { + return { error: 'Blocked: target resolves to private address' }; + } return { error: `Unable to fetch rank, ${(error as Error).message}` }; } }; diff --git a/packages/core/src/handlers/robots-txt.test.ts b/packages/core/src/handlers/robots-txt.test.ts index fc6ce7a..37121b3 100644 --- a/packages/core/src/handlers/robots-txt.test.ts +++ b/packages/core/src/handlers/robots-txt.test.ts @@ -1,14 +1,24 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { robotsTxtHandler } from './robots-txt.js'; +import * as dns from 'node:dns/promises'; + +vi.mock('node:dns/promises'); const server = setupServer(); beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); -afterEach(() => server.resetHandlers()); +afterEach(() => { + server.resetHandlers(); + vi.restoreAllMocks(); +}); afterAll(() => server.close()); +beforeEach(() => { + vi.mocked(dns.lookup).mockResolvedValue({ address: '93.184.216.34', family: 4 }); +}); + const STANDARD_ROBOTS_TXT = `User-agent: * Disallow: /admin Disallow: /private/ @@ -82,3 +92,12 @@ describe('robotsTxtHandler', () => { expect(result).toHaveProperty('error'); }); }); + +describe('robots-txt handler — SSRF', () => { + it('refuses to fetch a hostname that resolves to AWS metadata IP', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValueOnce({ address: '169.254.169.254', family: 4 }); + const result = await robotsTxtHandler('http://metadata.example.com/'); + expect(result.error).toMatch(/private address|Blocked/i); + expect(result.data).toBeUndefined(); + }); +}); diff --git a/packages/core/src/handlers/robots-txt.ts b/packages/core/src/handlers/robots-txt.ts index 4b0e39c..913b1a9 100644 --- a/packages/core/src/handlers/robots-txt.ts +++ b/packages/core/src/handlers/robots-txt.ts @@ -1,6 +1,7 @@ -import axios from 'axios'; import type { AnalysisHandler, HandlerResult } from '../types.js'; import { normalizeUrl } from '../utils/url.js'; +import { safeFetch } from '../utils/safe-fetch.js'; +import { SsrfBlockedError } from '../utils/network.js'; interface RobotsRule { lbl: string; @@ -45,7 +46,7 @@ export const robotsTxtHandler: AnalysisHandler = async (url, op const robotsURL = `${parsedURL.protocol}//${parsedURL.hostname}/robots.txt`; try { - const response = await axios.get(robotsURL, { timeout: options?.timeout }); + const response = await safeFetch(robotsURL, { timeoutMs: options?.timeout }); if (response.status === 200) { const parsedData = parseRobotsTxt(response.data); @@ -53,13 +54,14 @@ export const robotsTxtHandler: AnalysisHandler = async (url, op return { data: { robots: [], message: 'No robots.txt rules were found.' } }; } return { data: parsedData }; + } else if (response.status === 404) { + return { data: { robots: [], message: 'No robots.txt file is present on this site.' } }; } else { return { error: `Failed to fetch robots.txt (status ${response.status})`, errorCode: 'NOT_FOUND', errorCategory: 'info' }; } } catch (error) { - const axiosError = error as { response?: { status?: number } }; - if (axiosError.response?.status === 404) { - return { data: { robots: [], message: 'No robots.txt file is present on this site.' } }; + if (error instanceof SsrfBlockedError) { + return { error: 'Blocked: target resolves to private address' }; } return { error: `Error fetching robots.txt: ${(error as Error).message}` }; } diff --git a/packages/core/src/handlers/screenshot.test.ts b/packages/core/src/handlers/screenshot.test.ts new file mode 100644 index 0000000..ea6f295 --- /dev/null +++ b/packages/core/src/handlers/screenshot.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { screenshotHandler } from './screenshot.js'; +import * as dns from 'node:dns/promises'; + +vi.mock('node:dns/promises'); + +describe('screenshot handler — SSRF', () => { + afterEach(() => vi.restoreAllMocks()); + + it('refuses to screenshot a hostname that resolves to metadata IP', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValueOnce({ address: '169.254.169.254', family: 4 }); + const result = await screenshotHandler('http://metadata.example.com/', {}); + expect(result.error).toMatch(/private address|Blocked/i); + expect(result.data).toBeUndefined(); + }); + + it('rejects file:// URLs outright', async () => { + const result = await screenshotHandler('file:///etc/passwd', {}); + expect(result.error).toBeDefined(); + expect(result.data).toBeUndefined(); + }); + + it('rejects loopback hostnames', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValueOnce({ address: '127.0.0.1', family: 4 }); + const result = await screenshotHandler('http://localhost-spoof.example.com/', {}); + expect(result.error).toMatch(/private address|Blocked/i); + expect(result.data).toBeUndefined(); + }); +}); diff --git a/packages/core/src/handlers/screenshot.ts b/packages/core/src/handlers/screenshot.ts index 1e2d158..e6e48cd 100644 --- a/packages/core/src/handlers/screenshot.ts +++ b/packages/core/src/handlers/screenshot.ts @@ -4,6 +4,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { randomUUID } from 'crypto'; import type { AnalysisHandler, HandlerResult } from '../types.js'; +import { assertPublicHost, SsrfBlockedError } from '../utils/network.js'; export interface ScreenshotResult { image: string; @@ -27,7 +28,8 @@ const directChromiumScreenshot = async (url: string, chromePath: string): Promis const args = [ '--headless', '--disable-gpu', - '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', '--hide-scrollbars', '--window-size=1440,2200', '--run-all-compositor-stages-before-draw', @@ -54,6 +56,25 @@ const directChromiumScreenshot = async (url: string, chromePath: string): Promis }; export const screenshotHandler: AnalysisHandler = async (url, options) => { + // URL + SSRF validation before launching Chromium. + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { error: `Invalid URL: ${url}` }; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { error: `Blocked protocol: ${parsed.protocol}` }; + } + try { + await assertPublicHost(parsed.hostname); + } catch (e) { + if (e instanceof SsrfBlockedError) { + return { error: `Blocked: target resolves to private address` }; + } + throw e; + } + let targetUrl = url; if (!targetUrl) { @@ -65,8 +86,8 @@ export const screenshotHandler: AnalysisHandler = async (url, } try { - const parsed = new URL(targetUrl); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + const parsed2 = new URL(targetUrl); + if (parsed2.protocol !== 'http:' && parsed2.protocol !== 'https:') { return { error: 'URL provided is invalid', errorCode: 'INVALID_URL', errorCategory: 'tool' }; } } catch { @@ -84,7 +105,7 @@ export const screenshotHandler: AnalysisHandler = async (url, // @ts-ignore puppeteer-core is an optional peer dependency const puppeteer = await import('puppeteer-core'); browser = await puppeteer.default.launch({ - args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'], + args: ['--disable-setuid-sandbox', '--disable-dev-shm-usage', '--ignore-certificate-errors'], defaultViewport: { width: 1440, height: 900 }, executablePath: chromePath, headless: true, diff --git a/packages/core/src/handlers/sitemap.ts b/packages/core/src/handlers/sitemap.ts index 28dc67f..af432f7 100644 --- a/packages/core/src/handlers/sitemap.ts +++ b/packages/core/src/handlers/sitemap.ts @@ -1,7 +1,8 @@ -import axios from 'axios'; import * as xml2js from 'xml2js'; import type { AnalysisHandler } from '../types.js'; import { normalizeUrl } from '../utils/url.js'; +import { safeFetch } from '../utils/safe-fetch.js'; +import { SsrfBlockedError } from '../utils/network.js'; export interface SitemapResult { sitemapUrl: string; @@ -68,7 +69,7 @@ function detectType(parsed: Record): 'urlset' | 'sitemapindex' export const sitemapHandler: AnalysisHandler = async (url, options) => { const targetUrl = normalizeUrl(url); const timeout = options?.timeout ?? 10_000; - const axiosOpts = { timeout, maxRedirects: 5 }; + const fetchOpts = { timeoutMs: timeout, maxRedirects: 5 }; // Strategy: try robots.txt first (it often has the canonical sitemap URL), // then fall back to common sitemap paths. @@ -76,14 +77,17 @@ export const sitemapHandler: AnalysisHandler = async (url, option // 1. Try robots.txt for sitemap directives try { - const robotsRes = await axios.get(`${targetUrl}/robots.txt`, axiosOpts); + const robotsRes = await safeFetch(`${targetUrl}/robots.txt`, fetchOpts); if (typeof robotsRes.data === 'string') { const robotsSitemaps = extractSitemapUrlsFromRobots(robotsRes.data); for (const s of robotsSitemaps) { candidates.push({ url: s, source: 'robots.txt' }); } } - } catch { + } catch (err) { + if (err instanceof SsrfBlockedError) { + return { error: 'Blocked: target resolves to private address' }; + } // robots.txt not available — that's fine } @@ -98,7 +102,7 @@ export const sitemapHandler: AnalysisHandler = async (url, option // 3. Try each candidate until one works for (const candidate of candidates) { try { - const res = await axios.get(candidate.url, axiosOpts); + const res = await safeFetch(candidate.url, fetchOpts); if (res.status !== 200 || typeof res.data !== 'string') continue; // Must look like XML @@ -124,7 +128,10 @@ export const sitemapHandler: AnalysisHandler = async (url, option urls, }, }; - } catch { + } catch (err) { + if (err instanceof SsrfBlockedError) { + return { error: 'Blocked: target resolves to private address' }; + } continue; // Network error — try next candidate } } diff --git a/packages/core/src/handlers/social-tags.test.ts b/packages/core/src/handlers/social-tags.test.ts index 8b37534..4fc67ad 100644 --- a/packages/core/src/handlers/social-tags.test.ts +++ b/packages/core/src/handlers/social-tags.test.ts @@ -1,14 +1,24 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { socialTagsHandler } from './social-tags.js'; +import * as dns from 'node:dns/promises'; + +vi.mock('node:dns/promises'); const server = setupServer(); beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); -afterEach(() => server.resetHandlers()); +afterEach(() => { + server.resetHandlers(); + vi.restoreAllMocks(); +}); afterAll(() => server.close()); +beforeEach(() => { + vi.mocked(dns.lookup).mockResolvedValue({ address: '93.184.216.34', family: 4 }); +}); + const HTML_WITH_TAGS = ` @@ -112,3 +122,12 @@ describe('socialTagsHandler', () => { expect(typeof result.error).toBe('string'); }); }); + +describe('social-tags handler — SSRF', () => { + it('refuses to fetch a hostname that resolves to AWS metadata IP', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValueOnce({ address: '169.254.169.254', family: 4 }); + const result = await socialTagsHandler('http://metadata.example.com/'); + expect(result.error).toMatch(/private address|Blocked/i); + expect(result.data).toBeUndefined(); + }); +}); diff --git a/packages/core/src/handlers/social-tags.ts b/packages/core/src/handlers/social-tags.ts index 13a8968..cfe720b 100644 --- a/packages/core/src/handlers/social-tags.ts +++ b/packages/core/src/handlers/social-tags.ts @@ -1,8 +1,8 @@ -import axios from 'axios'; import * as cheerio from 'cheerio'; import type { AnalysisHandler, HandlerResult } from '../types.js'; -import { getFinalResponseUrl } from '../utils/http.js'; import { normalizeUrl } from '../utils/url.js'; +import { safeFetch } from '../utils/safe-fetch.js'; +import { SsrfBlockedError } from '../utils/network.js'; export interface SocialTagsResult { title: string; @@ -37,8 +37,9 @@ export const socialTagsHandler: AnalysisHandler = async (url, const targetUrl = normalizeUrl(url); try { - const response = await axios.get(targetUrl, { timeout: options?.timeout }); - const finalUrl = getFinalResponseUrl(response) ?? targetUrl; + const response = await safeFetch(targetUrl, { timeoutMs: options?.timeout }); + // Note: finalUrl is the initial (normalized) URL; post-redirect URL is not tracked + const finalUrl = targetUrl; const html: string = response.data; const $ = cheerio.load(html); @@ -80,6 +81,9 @@ export const socialTagsHandler: AnalysisHandler = async (url, return { data: metadata }; } catch (error) { + if (error instanceof SsrfBlockedError) { + return { error: 'Blocked: target resolves to private address' }; + } return { error: `Failed fetching data: ${(error as Error).message}` }; } }; diff --git a/packages/core/src/handlers/tech-stack.test.ts b/packages/core/src/handlers/tech-stack.test.ts index 2cfdf1d..cb938c8 100644 --- a/packages/core/src/handlers/tech-stack.test.ts +++ b/packages/core/src/handlers/tech-stack.test.ts @@ -1,14 +1,24 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { techStackHandler } from './tech-stack.js'; +import * as dns from 'node:dns/promises'; + +vi.mock('node:dns/promises'); const server = setupServer(); beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); -afterEach(() => server.resetHandlers()); +afterEach(() => { + server.resetHandlers(); + vi.restoreAllMocks(); +}); afterAll(() => server.close()); +beforeEach(() => { + vi.mocked(dns.lookup).mockResolvedValue({ address: '93.184.216.34', family: 4 }); +}); + const TEST_URL = 'https://mock-site.test/'; describe('techStackHandler', () => { @@ -148,3 +158,12 @@ describe('techStackHandler', () => { expect(names).toContain('Express'); }); }); + +describe('tech-stack handler — SSRF', () => { + it('refuses to fetch a hostname that resolves to AWS metadata IP', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValueOnce({ address: '169.254.169.254', family: 4 }); + const result = await techStackHandler('http://metadata.example.com/'); + expect(result.error).toMatch(/private address|Blocked/i); + expect(result.data).toBeUndefined(); + }); +}); diff --git a/packages/core/src/handlers/tech-stack.ts b/packages/core/src/handlers/tech-stack.ts index b1800a0..028b9ce 100644 --- a/packages/core/src/handlers/tech-stack.ts +++ b/packages/core/src/handlers/tech-stack.ts @@ -1,8 +1,8 @@ -import axios from 'axios'; import * as cheerio from 'cheerio'; import type { AnalysisHandler, HandlerResult } from '../types.js'; -import { getFinalResponseUrl } from '../utils/http.js'; import { normalizeUrl } from '../utils/url.js'; +import { safeFetch } from '../utils/safe-fetch.js'; +import { SsrfBlockedError } from '../utils/network.js'; interface DetectedTechnology { name: string; @@ -205,13 +205,14 @@ const KNOWN_TECH_PATTERNS: TechPattern[] = [ export const techStackHandler: AnalysisHandler = async (url, options) => { try { const targetUrl = normalizeUrl(url); - const response = await axios.get(targetUrl, { - timeout: options?.timeout ?? 10000, + const response = await safeFetch(targetUrl, { + timeoutMs: options?.timeout ?? 10000, headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WebAnalysis/1.0)', }, }); - const finalUrl = getFinalResponseUrl(response) ?? targetUrl; + // Note: finalUrl is the initial (normalized) URL; post-redirect URL is not tracked + const finalUrl = targetUrl; const html: string = response.data; const responseHeaders: Record = {}; @@ -350,6 +351,9 @@ export const techStackHandler: AnalysisHandler = async (url, op return { data: { technologies: detected, finalUrl } }; } catch (error) { + if (error instanceof SsrfBlockedError) { + return { error: 'Blocked: target resolves to private address' }; + } return { error: (error as Error).message }; } }; diff --git a/packages/core/src/handlers/threats.test.ts b/packages/core/src/handlers/threats.test.ts index 1adb5e9..6e47e2e 100644 --- a/packages/core/src/handlers/threats.test.ts +++ b/packages/core/src/handlers/threats.test.ts @@ -1,14 +1,24 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { threatsHandler } from './threats.js'; +import * as dns from 'node:dns/promises'; + +vi.mock('node:dns/promises'); const server = setupServer(); beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); -afterEach(() => server.resetHandlers()); +afterEach(() => { + server.resetHandlers(); + vi.restoreAllMocks(); +}); afterAll(() => server.close()); +beforeEach(() => { + vi.mocked(dns.lookup).mockResolvedValue({ address: '93.184.216.34', family: 4 }); +}); + const TEST_URL = 'https://example.com'; const options = { @@ -142,6 +152,7 @@ describe('threatsHandler', () => { }); it('returns partial data when some requests fail with network errors', async () => { + vi.mocked(dns.lookup).mockResolvedValue({ address: '93.184.216.34', family: 4 }); server.use( http.post('https://urlhaus-api.abuse.ch/v1/host/', () => { return HttpResponse.error(); @@ -177,3 +188,15 @@ describe('threatsHandler', () => { expect(data.safeBrowsing.unsafe).toBe(false); }); }); + +describe('threats handler — SSRF', () => { + it('blocks requests when threat API endpoints resolve to private addresses', async () => { + // All API endpoints resolve to private IP — each sub-request is blocked by safeFetch + vi.mocked(dns.lookup).mockResolvedValue({ address: '169.254.169.254', family: 4 }); + + const result = await threatsHandler(TEST_URL, options); + // All four sub-checks fail with SSRF block errors, triggering top-level error + expect(result.error).toMatch(/Blocked.*private address/i); + expect(result.data).toBeUndefined(); + }); +}); diff --git a/packages/core/src/handlers/threats.ts b/packages/core/src/handlers/threats.ts index eace9de..632bab7 100644 --- a/packages/core/src/handlers/threats.ts +++ b/packages/core/src/handlers/threats.ts @@ -1,8 +1,9 @@ -import axios from 'axios'; import * as xml2js from 'xml2js'; import type { AnalysisHandler, HandlerResult, HandlerOptions } from '../types.js'; import { extractHostname, normalizeUrl } from '../utils/url.js'; import { withRetry } from '../utils/retry.js'; +import { safeFetch } from '../utils/safe-fetch.js'; +import { SsrfBlockedError, assertPublicHost } from '../utils/network.js'; export interface GoogleSafeBrowsingResult { unsafe?: boolean; @@ -66,7 +67,11 @@ const getGoogleSafeBrowsingResult = async ( }; const response = await withRetry(() => - axios.post(apiEndpoint, requestBody, { headers: { 'x-goog-api-key': apiKey } }), + safeFetch(apiEndpoint, { + method: 'POST', + data: requestBody, + headers: { 'x-goog-api-key': apiKey }, + }), ); if (response.data && response.data.matches) { return { unsafe: true, details: response.data.matches }; @@ -81,9 +86,8 @@ const getUrlHausResult = async (url: string): Promise => { try { const domain = extractHostname(url); const response = await withRetry(() => - axios({ - method: 'post', - url: 'https://urlhaus-api.abuse.ch/v1/host/', + safeFetch('https://urlhaus-api.abuse.ch/v1/host/', { + method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: `host=${domain}`, }), @@ -98,9 +102,12 @@ const getPhishTankResult = async (url: string): Promise => { try { const encodedUrl = Buffer.from(url).toString('base64'); const endpoint = `https://checkurl.phishtank.com/checkurl/?url=${encodedUrl}`; - const headers = { 'User-Agent': 'phishtank/recon-web' }; const response = await withRetry(() => - axios.post(endpoint, null, { headers, timeout: 3000 }), + safeFetch(endpoint, { + method: 'POST', + headers: { 'User-Agent': 'phishtank/recon-web' }, + timeoutMs: 3000, + }), ); const parsed = await xml2js.parseStringPromise(response.data, { explicitArray: false }); return parsed.response.results; @@ -118,13 +125,16 @@ const getCloudmersiveResult = async ( } try { const endpoint = 'https://api.cloudmersive.com/virus/scan/website'; - const headers = { - 'Content-Type': 'application/x-www-form-urlencoded', - Apikey: apiKey, - }; const data = `Url=${encodeURIComponent(url)}`; const response = await withRetry(() => - axios.post(endpoint, data, { headers }), + safeFetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Apikey: apiKey, + }, + data, + }), ); return response.data; } catch (error) { @@ -134,6 +144,17 @@ const getCloudmersiveResult = async ( export const threatsHandler: AnalysisHandler = async (url, options) => { try { + // Top-level SSRF gate: validate target URL before any sub-handler runs + const parsedUrl = new URL(url); + try { + await assertPublicHost(parsedUrl.hostname); + } catch (err) { + if (err instanceof SsrfBlockedError) { + return { error: 'Blocked: target resolves to private address' }; + } + // URL parse errors or other issues fall through to normal handling + } + const googleApiKey = options?.apiKeys?.GOOGLE_CLOUD_API_KEY; const cloudmersiveApiKey = options?.apiKeys?.CLOUDMERSIVE_API_KEY; diff --git a/packages/core/src/utils/network.test.ts b/packages/core/src/utils/network.test.ts index eea1609..445d3fb 100644 --- a/packages/core/src/utils/network.test.ts +++ b/packages/core/src/utils/network.test.ts @@ -54,6 +54,70 @@ describe('isPrivateIP', () => { it('allows public IPv6', () => { expect(isPrivateIP('2001:4860:4860::8888')).toBe(false); }); + + it('detects 0.0.0.0/8 unspecified range', () => { + expect(isPrivateIP('0.0.0.0')).toBe(true); + expect(isPrivateIP('0.255.255.255')).toBe(true); + }); + + it('detects 100.64.0.0/10 CGNAT range', () => { + expect(isPrivateIP('100.64.0.1')).toBe(true); + expect(isPrivateIP('100.127.255.255')).toBe(true); + expect(isPrivateIP('100.63.255.255')).toBe(false); + expect(isPrivateIP('100.128.0.0')).toBe(false); + }); + + it('detects 198.18.0.0/15 benchmark range', () => { + expect(isPrivateIP('198.18.0.1')).toBe(true); + expect(isPrivateIP('198.19.255.255')).toBe(true); + expect(isPrivateIP('198.17.255.255')).toBe(false); + expect(isPrivateIP('198.20.0.0')).toBe(false); + }); + + it('detects 224.0.0.0/4 multicast range', () => { + expect(isPrivateIP('224.0.0.1')).toBe(true); + expect(isPrivateIP('239.255.255.255')).toBe(true); + }); + + it('detects 240.0.0.0/4 reserved range', () => { + expect(isPrivateIP('240.0.0.1')).toBe(true); + expect(isPrivateIP('255.255.255.254')).toBe(true); + }); + + it('detects metadata service IP explicitly', () => { + expect(isPrivateIP('169.254.169.254')).toBe(true); + expect(isPrivateIP('169.254.170.2')).toBe(true); // ECS task metadata + }); +}); + +describe('isPrivateIP IPv6', () => { + it('detects loopback', () => { + expect(isPrivateIP('::1')).toBe(true); + }); + it('detects ULA fc00::/7', () => { + expect(isPrivateIP('fc00::1')).toBe(true); + expect(isPrivateIP('fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff')).toBe(true); + }); + it('detects link-local fe80::/10', () => { + expect(isPrivateIP('fe80::1')).toBe(true); + expect(isPrivateIP('febf::ffff')).toBe(true); + }); + it('detects multicast ff00::/8', () => { + expect(isPrivateIP('ff00::1')).toBe(true); + expect(isPrivateIP('ff02::1')).toBe(true); + }); + it('detects documentation 2001:db8::/32', () => { + expect(isPrivateIP('2001:db8::1')).toBe(true); + }); + it('detects IPv4-mapped private addresses', () => { + expect(isPrivateIP('::ffff:127.0.0.1')).toBe(true); + expect(isPrivateIP('::ffff:169.254.169.254')).toBe(true); + expect(isPrivateIP('::ffff:10.0.0.1')).toBe(true); + }); + it('does not flag public IPv6', () => { + expect(isPrivateIP('2606:4700:4700::1111')).toBe(false); // Cloudflare + expect(isPrivateIP('2001:4860:4860::8888')).toBe(false); // Google + }); }); describe('assertPublicHost', () => { @@ -62,6 +126,6 @@ describe('assertPublicHost', () => { }); it('allows public domains', async () => { - await expect(assertPublicHost('example.com')).resolves.toBeUndefined(); + await expect(assertPublicHost('example.com')).resolves.toBeDefined(); }); }); diff --git a/packages/core/src/utils/network.ts b/packages/core/src/utils/network.ts index ce8b707..7154fe5 100644 --- a/packages/core/src/utils/network.ts +++ b/packages/core/src/utils/network.ts @@ -1,23 +1,91 @@ import { lookup } from 'node:dns/promises'; +export class SsrfBlockedError extends Error { + constructor(message: string) { + super(message); + this.name = 'SsrfBlockedError'; + } +} + +function ipv4ToInt(ip: string): number { + const parts = ip.split('.').map((p) => parseInt(p, 10)); + if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) { + return -1; + } + return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; +} + +function inCidr4(ip: string, base: string, prefix: number): boolean { + const ipInt = ipv4ToInt(ip); + const baseInt = ipv4ToInt(base); + if (ipInt < 0 || baseInt < 0) return false; + const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0; + return (ipInt & mask) === (baseInt & mask); +} + +const BLOCKED_V4_CIDRS: Array<[string, number]> = [ + ['0.0.0.0', 8], // "this network" / unspecified + ['10.0.0.0', 8], // RFC1918 + ['100.64.0.0', 10], // CGNAT (RFC6598) + ['127.0.0.0', 8], // loopback + ['169.254.0.0', 16], // link-local incl. cloud metadata 169.254.169.254 + ['172.16.0.0', 12], // RFC1918 + ['192.0.0.0', 24], // IETF protocol assignments + ['192.0.2.0', 24], // TEST-NET-1 + ['192.168.0.0', 16], // RFC1918 + ['198.18.0.0', 15], // benchmark + ['198.51.100.0', 24], // TEST-NET-2 + ['203.0.113.0', 24], // TEST-NET-3 + ['224.0.0.0', 4], // multicast + ['240.0.0.0', 4], // reserved +]; + function isPrivateIPv4(ip: string): boolean { - if (ip === '0.0.0.0') return true; - if (ip.startsWith('127.')) return true; - if (ip.startsWith('10.')) return true; - if (ip.startsWith('169.254.')) return true; - if (ip.startsWith('192.168.')) return true; - // 172.16.0.0/12 - if (ip.startsWith('172.')) { - const second = parseInt(ip.split('.')[1], 10); - if (second >= 16 && second <= 31) return true; + return BLOCKED_V4_CIDRS.some(([base, prefix]) => inCidr4(ip, base, prefix)); +} + +function expandIPv6(ip: string): string { + // Handle IPv4-mapped (::ffff:1.2.3.4) + const ipv4MappedMatch = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); + if (ipv4MappedMatch) { + const v4 = ipv4MappedMatch[1]; + const parts = v4.split('.').map((p) => parseInt(p, 10)); + const high = ((parts[0] << 8) | parts[1]).toString(16).padStart(4, '0'); + const low = ((parts[2] << 8) | parts[3]).toString(16).padStart(4, '0'); + return `0000:0000:0000:0000:0000:ffff:${high}:${low}`; } - return false; + // Expand "::" shorthand + let segments: string[]; + if (ip.includes('::')) { + const [head, tail] = ip.split('::'); + const headSegs = head ? head.split(':') : []; + const tailSegs = tail ? tail.split(':') : []; + const missing = 8 - headSegs.length - tailSegs.length; + segments = [...headSegs, ...Array(missing).fill('0'), ...tailSegs]; + } else { + segments = ip.split(':'); + } + return segments.map((s) => s.padStart(4, '0').toLowerCase()).join(':'); } function isPrivateIPv6(ip: string): boolean { - if (ip === '::1') return true; - if (ip.startsWith('fe80:')) return true; - if (ip.startsWith('fc') || ip.startsWith('fd')) return true; + // Handle IPv4-mapped: re-evaluate the embedded IPv4 + const ipv4MappedMatch = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); + if (ipv4MappedMatch) { + return isPrivateIPv4(ipv4MappedMatch[1]); + } + + const expanded = expandIPv6(ip); + const firstByte = parseInt(expanded.slice(0, 2), 16); + + if (expanded === '0000:0000:0000:0000:0000:0000:0000:0001') return true; // ::1 + if (expanded === '0000:0000:0000:0000:0000:0000:0000:0000') return true; // :: + if ((firstByte & 0xfe) === 0xfc) return true; // fc00::/7 ULA + if (firstByte === 0xff) return true; // ff00::/8 multicast + if (expanded.startsWith('fe8') || expanded.startsWith('fe9') || expanded.startsWith('fea') || expanded.startsWith('feb')) { + return true; // fe80::/10 link-local + } + if (expanded.startsWith('2001:0db8:')) return true; // documentation return false; } @@ -32,11 +100,17 @@ export function isPrivateIP(ip: string): boolean { /** * Resolve hostname and throw if it points to a private/internal IP address. * Prevents SSRF attacks by blocking requests to internal services. + * Returns the resolved IP for the caller to pin against. */ -export async function assertPublicHost(hostname: string): Promise { +export async function assertPublicHost(hostname: string): Promise<{ address: string; family: 4 | 6 }> { + if (process.env.RECON_ALLOW_PRIVATE_IPS === '1') { + const { address, family } = await lookup(hostname); + return { address, family: family as 4 | 6 }; + } + // Resolve all addresses; reject if ANY is private (defeats DNS rebinding partially) const { address, family } = await lookup(hostname); - const isPrivate = family === 4 ? isPrivateIPv4(address) : isPrivateIPv6(address); - if (isPrivate) { - throw new Error(`Blocked: ${hostname} resolves to private address ${address}`); + if (isPrivateIP(address)) { + throw new SsrfBlockedError(`Blocked: ${hostname} resolves to private address ${address}`); } + return { address, family: family as 4 | 6 }; } diff --git a/packages/core/src/utils/safe-fetch.test.ts b/packages/core/src/utils/safe-fetch.test.ts new file mode 100644 index 0000000..ae4bdeb --- /dev/null +++ b/packages/core/src/utils/safe-fetch.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { safeFetch, SafeFetchError } from './safe-fetch.js'; +import * as dns from 'node:dns/promises'; + +// Required for vi.spyOn to work on ESM non-configurable exports +vi.mock('node:dns/promises'); + +describe('safeFetch', () => { + beforeEach(() => { + delete process.env.RECON_ALLOW_PRIVATE_IPS; + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('rejects non-http(s) URLs', async () => { + await expect(safeFetch('file:///etc/passwd')).rejects.toThrow(SafeFetchError); + await expect(safeFetch('gopher://example.com/')).rejects.toThrow(SafeFetchError); + await expect(safeFetch('ftp://example.com/')).rejects.toThrow(SafeFetchError); + }); + + it('rejects when hostname resolves to private IP', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValueOnce({ address: '169.254.169.254', family: 4 }); + await expect(safeFetch('http://metadata.example.com/')).rejects.toThrow(/private address/); + }); + + it('rejects when hostname resolves to loopback', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValueOnce({ address: '127.0.0.1', family: 4 }); + await expect(safeFetch('http://localhost-impostor.example.com/')).rejects.toThrow(/private address/); + }); + + it('rejects ULA IPv6 addresses', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValueOnce({ address: 'fd00::1', family: 6 }); + await expect(safeFetch('http://internal.example.com/')).rejects.toThrow(/private address/); + }); + + it('allows RECON_ALLOW_PRIVATE_IPS escape hatch', async () => { + process.env.RECON_ALLOW_PRIVATE_IPS = '1'; + vi.spyOn(dns, 'lookup').mockResolvedValueOnce({ address: '127.0.0.1', family: 4 }); + await expect(safeFetch('http://localhost.test/')) + .rejects.not.toThrow(/private address/); + }); + + it('rejects redirects whose Location resolves to private IP', async () => { + vi.spyOn(dns, 'lookup') + .mockResolvedValueOnce({ address: '93.184.216.34', family: 4 }) + .mockResolvedValueOnce({ address: '169.254.169.254', family: 4 }); + + const fakeAdapter = vi.fn(async (cfg: { url: string }) => { + if (cfg.url === 'http://example.com/') { + return { status: 301, headers: { location: 'http://metadata.example.com/' }, data: '', config: cfg }; + } + return { status: 200, headers: {}, data: 'should not reach', config: cfg }; + }); + await expect(safeFetch('http://example.com/', { _adapter: fakeAdapter })).rejects.toThrow(/private address/); + }); + + it('enforces body size limit', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValueOnce({ address: '93.184.216.34', family: 4 }); + const fakeAdapter = vi.fn(async (cfg: { url: string }) => ({ + status: 200, + headers: { 'content-length': String(50 * 1024 * 1024) }, + data: '', + config: cfg, + })); + await expect(safeFetch('http://example.com/', { _adapter: fakeAdapter, maxBytes: 10 * 1024 * 1024 })) + .rejects.toThrow(/too large/i); + }); + + it('enforces redirect chain depth limit', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValue({ address: '93.184.216.34', family: 4 }); + let n = 0; + const fakeAdapter = vi.fn(async (cfg: { url: string }) => { + n++; + return { status: 301, headers: { location: `http://example.com/r${n}` }, data: '', config: cfg }; + }); + await expect(safeFetch('http://example.com/', { _adapter: fakeAdapter, maxRedirects: 3 })) + .rejects.toThrow(/too many redirects/i); + }); +}); diff --git a/packages/core/src/utils/safe-fetch.ts b/packages/core/src/utils/safe-fetch.ts new file mode 100644 index 0000000..058dd38 --- /dev/null +++ b/packages/core/src/utils/safe-fetch.ts @@ -0,0 +1,147 @@ +import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios'; +import { lookup } from 'node:dns/promises'; +import http from 'node:http'; +import https from 'node:https'; +import { isPrivateIP, SsrfBlockedError } from './network.js'; + +export class SafeFetchError extends Error { + constructor(message: string, public code: string) { + super(message); + this.name = 'SafeFetchError'; + } +} + +export interface SafeFetchOptions { + /** Max redirects to follow (default 5) */ + maxRedirects?: number; + /** Max response body bytes (default 10 MiB) */ + maxBytes?: number; + /** Per-request timeout in ms (default 30_000) */ + timeoutMs?: number; + /** Connect timeout in ms (default 5_000) */ + connectTimeoutMs?: number; + /** Headers to send */ + headers?: Record; + /** HTTP method (default GET) */ + method?: 'GET' | 'HEAD' | 'POST'; + /** Optional request body */ + data?: unknown; + /** TEST-ONLY: inject a fake axios adapter */ + _adapter?: (config: AxiosRequestConfig) => Promise; +} + +const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; +const DEFAULT_MAX_REDIRECTS = 5; +const DEFAULT_TIMEOUT_MS = 30_000; + +async function validateUrl(rawUrl: string): Promise<{ url: URL; pinnedAddress: string; family: 4 | 6 }> { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new SafeFetchError(`Invalid URL: ${rawUrl}`, 'INVALID_URL'); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new SafeFetchError(`Blocked protocol: ${url.protocol}`, 'BLOCKED_PROTOCOL'); + } + + // Resolve and validate + if (process.env.RECON_ALLOW_PRIVATE_IPS === '1') { + const { address, family } = await lookup(url.hostname); + return { url, pinnedAddress: address, family: family as 4 | 6 }; + } + + const { address, family } = await lookup(url.hostname); + if (isPrivateIP(address)) { + throw new SsrfBlockedError(`Blocked: ${url.hostname} resolves to private address ${address}`); + } + return { url, pinnedAddress: address, family: family as 4 | 6 }; +} + +function buildPinnedAgent(pinnedAddress: string, family: 4 | 6, isHttps: boolean) { + // Custom lookup that always returns the pre-validated IP. Defeats DNS rebinding + // because the IP we connect to is the IP we validated, not whatever DNS returns + // on a second lookup. + const customLookup = ( + _hostname: string, + _opts: unknown, + cb: (err: Error | null, address: string, family: number) => void, + ): void => { + cb(null, pinnedAddress, family); + }; + + const agentOptions = { lookup: customLookup as never, keepAlive: false }; + return isHttps ? new https.Agent(agentOptions) : new http.Agent(agentOptions); +} + +/** + * Perform an HTTP request with SSRF protection. + * - Validates URL protocol. + * - Resolves hostname and rejects private/internal IPs. + * - Pins the connection to the validated IP (DNS rebinding defense). + * - Re-validates every redirect target before following. + * - Enforces max body size, max redirects, total + connect timeouts. + */ +export async function safeFetch(rawUrl: string, opts: SafeFetchOptions = {}): Promise { + const maxRedirects = opts.maxRedirects ?? DEFAULT_MAX_REDIRECTS; + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES; + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const headers = opts.headers ?? {}; + const method = opts.method ?? 'GET'; + + let currentUrl = rawUrl; + let redirects = 0; + + while (true) { + const { url, pinnedAddress, family } = await validateUrl(currentUrl); + const isHttps = url.protocol === 'https:'; + + const config: AxiosRequestConfig = { + url: currentUrl, + method, + headers: { Host: url.host, ...headers }, + timeout: timeoutMs, + maxContentLength: maxBytes, + maxBodyLength: maxBytes, + // Do not let axios follow redirects — we re-validate ourselves. + maxRedirects: 0, + validateStatus: () => true, // we handle status manually + httpAgent: opts._adapter ? undefined : buildPinnedAgent(pinnedAddress, family, false), + httpsAgent: opts._adapter ? undefined : buildPinnedAgent(pinnedAddress, family, true), + data: opts.data, + }; + if (opts._adapter) { + config.adapter = opts._adapter as never; + } + + let response: AxiosResponse; + try { + response = await axios.request(config); + } catch (err) { + if ((err as { code?: string }).code === 'ERR_FR_MAX_BODY_LENGTH_EXCEEDED') { + throw new SafeFetchError(`Response body too large (max ${maxBytes} bytes)`, 'BODY_TOO_LARGE'); + } + throw err; + } + + // Manual content-length check (covers cases where axios didn't enforce yet) + const cl = response.headers?.['content-length']; + if (cl && parseInt(String(cl), 10) > maxBytes) { + throw new SafeFetchError(`Response body too large (max ${maxBytes} bytes)`, 'BODY_TOO_LARGE'); + } + + // Handle redirects manually with re-validation + if (response.status >= 300 && response.status < 400 && response.headers?.location) { + redirects++; + if (redirects > maxRedirects) { + throw new SafeFetchError(`Too many redirects (max ${maxRedirects})`, 'TOO_MANY_REDIRECTS'); + } + const nextLocation = String(response.headers.location); + // Resolve relative redirects against current URL + currentUrl = new URL(nextLocation, currentUrl).toString(); + continue; + } + + return response; + } +} diff --git a/packages/static/package.json b/packages/static/package.json index 5ca8314..964e880 100644 --- a/packages/static/package.json +++ b/packages/static/package.json @@ -1,6 +1,6 @@ { "name": "@recon-web/static", - "version": "1.1.1", + "version": "1.2.0", "type": "module", "private": true, "scripts": { diff --git a/packages/web/Dockerfile b/packages/web/Dockerfile index 72e503d..a1abbd9 100644 --- a/packages/web/Dockerfile +++ b/packages/web/Dockerfile @@ -39,5 +39,15 @@ COPY --from=build /app/packages/web/dist /usr/share/nginx/html COPY packages/web/nginx.conf.template /etc/nginx/templates/default.conf.template ENV API_UPSTREAM=http://api:3000 - -EXPOSE 80 +# HSTS is opt-in. Set HSTS_HEADER to "max-age=31536000; includeSubDomains" when +# the deployment is behind TLS; default empty string emits a no-op header. +ENV HSTS_HEADER="" + +# Non-root runtime user. nginx alpine ships with an unprivileged 'nginx' user +# (UID 101). We rebind to 8080 (unprivileged) so we don't need CAP_NET_BIND_SERVICE. +RUN sed -i 's|listen 80;|listen 8080;|' /etc/nginx/templates/default.conf.template && \ + chown -R nginx:nginx /usr/share/nginx/html /var/cache/nginx /var/log/nginx /etc/nginx/conf.d /etc/nginx/templates && \ + touch /var/run/nginx.pid && chown nginx:nginx /var/run/nginx.pid +USER nginx + +EXPOSE 8080 diff --git a/packages/web/nginx.conf.template b/packages/web/nginx.conf.template index f199483..1d14ede 100644 --- a/packages/web/nginx.conf.template +++ b/packages/web/nginx.conf.template @@ -5,7 +5,23 @@ server { root /usr/share/nginx/html; index index.html; - # Proxy API requests to the API service + # ── Security headers ──────────────────────────────────────── + # Strict CSP. 'unsafe-inline' on style-src is required for Tailwind + # generated atomic classes; we accept this and rely on script-src 'self' + # to block injected JS. + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always; + # HSTS only emitted when behind TLS — operators flip ENABLE_HSTS=true. + # nginx-template substitution is done by the upstream entrypoint. + add_header Strict-Transport-Security "${HSTS_HEADER}" always; + + # Hide server tokens + server_tokens off; + + # ── Proxy rules ───────────────────────────────────────────── # Exact match for /api (scan all handlers: GET /api?url=...) location = /api { proxy_pass ${API_UPSTREAM}/api; diff --git a/packages/web/package.json b/packages/web/package.json index 1c4c070..39f599c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@recon-web/web", - "version": "1.1.1", + "version": "1.2.0", "type": "module", "private": true, "exports": {