From 8ed704463954b6fb6dc08ac0be8c843b28c40157 Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Fri, 10 Apr 2026 21:25:04 +0200 Subject: [PATCH 01/12] remove helm chart The Helm chart was unmaintained and not production-hardened (no securityContext, no resource limits, no NetworkPolicy, plaintext secrets in values.yaml). Recommended deployment is Docker Compose. K8s users can write a fresh chart against the v1.2.0 images. --- helm/recon-web/Chart.yaml | 6 -- helm/recon-web/templates/api-deployment.yaml | 62 ------------- helm/recon-web/templates/api-service.yaml | 18 ---- helm/recon-web/templates/configmap.yaml | 41 --------- helm/recon-web/templates/cronjob.yaml | 29 ------ helm/recon-web/templates/ingress.yaml | 48 ---------- helm/recon-web/templates/pvc.yaml | 18 ---- helm/recon-web/templates/secret.yaml | 28 ------ helm/recon-web/templates/web-deployment.yaml | 40 --------- helm/recon-web/templates/web-service.yaml | 18 ---- helm/recon-web/values.yaml | 94 -------------------- 11 files changed, 402 deletions(-) delete mode 100644 helm/recon-web/Chart.yaml delete mode 100644 helm/recon-web/templates/api-deployment.yaml delete mode 100644 helm/recon-web/templates/api-service.yaml delete mode 100644 helm/recon-web/templates/configmap.yaml delete mode 100644 helm/recon-web/templates/cronjob.yaml delete mode 100644 helm/recon-web/templates/ingress.yaml delete mode 100644 helm/recon-web/templates/pvc.yaml delete mode 100644 helm/recon-web/templates/secret.yaml delete mode 100644 helm/recon-web/templates/web-deployment.yaml delete mode 100644 helm/recon-web/templates/web-service.yaml delete mode 100644 helm/recon-web/values.yaml 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 From aed956ce0e18c3bf85097d322c49e9b09fbe1cea Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Fri, 10 Apr 2026 21:36:08 +0200 Subject: [PATCH 02/12] core(network): extend isPrivateIP to cover CGNAT, multicast, reserved, mapped IPv6 Adds CIDR-based matching for previously uncovered ranges: - IPv4: 0.0.0.0/8, 100.64.0.0/10 (CGNAT), 198.18.0.0/15 (benchmark), 224.0.0.0/4 (multicast), 240.0.0.0/4 (reserved), TEST-NETs. - IPv6: ::1, fc00::/7 (ULA), fe80::/10 (link-local), ff00::/8 (multicast), 2001:db8::/32 (documentation), and IPv4-mapped addresses which are re-evaluated against the IPv4 blocklist. Adds RECON_ALLOW_PRIVATE_IPS=1 escape hatch for local testing. Adds SsrfBlockedError class for typed error handling. assertPublicHost now returns the resolved IP for callers to pin against. --- packages/core/src/utils/network.test.ts | 66 ++++++++++++++- packages/core/src/utils/network.ts | 108 ++++++++++++++++++++---- 2 files changed, 156 insertions(+), 18 deletions(-) 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 }; } From b20a27bd908bbf42e1415436a8662784a30504e4 Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Fri, 10 Apr 2026 22:27:45 +0200 Subject: [PATCH 03/12] core(safe-fetch): SSRF-hardened HTTP client with DNS pinning + redirect revalidation New utility wrapping axios with: - Protocol allowlist (http/https only) - DNS resolution + assertPublicHost on the resolved IP - Connection pinning to the validated IP (defeats DNS rebinding) - Manual redirect handling with full re-validation on every Location - Max body size (default 10 MiB), max redirects (default 5), total timeout (default 30s), connect timeout (default 5s) All handlers that perform outbound HTTP will be migrated to safeFetch in subsequent commits. RECON_ALLOW_PRIVATE_IPS=1 escape hatch supported. --- packages/core/src/utils/safe-fetch.test.ts | 82 ++++++++++++ packages/core/src/utils/safe-fetch.ts | 147 +++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 packages/core/src/utils/safe-fetch.test.ts create mode 100644 packages/core/src/utils/safe-fetch.ts 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; + } +} From f8d1a005d704736c3199e1b2a34c0298734a1242 Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Fri, 10 Apr 2026 22:40:53 +0200 Subject: [PATCH 04/12] core(handlers): migrate all outbound HTTP from axios to safeFetch (SSRF guard) Migrates 14 handlers from raw axios.get() to safeFetch: archives, cookies, firewall, headers, http-security, legacy-rank, linked-pages, quality, rank, robots-txt, sitemap, social-tags, tech-stack, threats. Each handler now: - Validates URL protocol before fetching. - Resolves DNS and rejects private/internal IPs. - Pins connection to the validated IP (DNS rebinding defense). - Re-validates redirect targets before following. - Returns { error: 'Blocked: ...' } on SsrfBlockedError instead of leaking the underlying network error. Adds per-handler SSRF integration tests using mocked dns.lookup. --- packages/core/src/handlers/archives.ts | 9 +++-- packages/core/src/handlers/cookies.ts | 25 +++++--------- packages/core/src/handlers/firewall.test.ts | 23 +++++++++++-- packages/core/src/handlers/firewall.ts | 11 +++--- packages/core/src/handlers/headers.test.ts | 24 +++++++++++-- packages/core/src/handlers/headers.ts | 16 +++++---- .../core/src/handlers/http-security.test.ts | 23 +++++++++++-- packages/core/src/handlers/http-security.ts | 12 ++++--- packages/core/src/handlers/legacy-rank.ts | 8 +++++ packages/core/src/handlers/linked-pages.ts | 12 ++++--- packages/core/src/handlers/quality.test.ts | 24 +++++++++++-- packages/core/src/handlers/quality.ts | 24 +++++++------ packages/core/src/handlers/rank.test.ts | 24 +++++++++++-- packages/core/src/handlers/rank.ts | 19 +++++++---- packages/core/src/handlers/robots-txt.test.ts | 23 +++++++++++-- packages/core/src/handlers/robots-txt.ts | 12 ++++--- packages/core/src/handlers/sitemap.ts | 19 +++++++---- .../core/src/handlers/social-tags.test.ts | 23 +++++++++++-- packages/core/src/handlers/social-tags.ts | 12 ++++--- packages/core/src/handlers/tech-stack.test.ts | 23 +++++++++++-- packages/core/src/handlers/tech-stack.ts | 14 +++++--- packages/core/src/handlers/threats.test.ts | 28 +++++++++++++-- packages/core/src/handlers/threats.ts | 34 ++++++++++++------- 23 files changed, 339 insertions(+), 103 deletions(-) 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/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..4f77bbb 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,16 @@ 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).toBeDefined(); + expect(typeof result.error).toBe('string'); + expect(result.data).toBeUndefined(); + }); +}); diff --git a/packages/core/src/handlers/threats.ts b/packages/core/src/handlers/threats.ts index eace9de..397c91b 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 } 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) { From 2edd4b027f6c2f6f14fd4a62a70934be4d028f52 Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Fri, 10 Apr 2026 22:53:52 +0200 Subject: [PATCH 05/12] core(threats): return canonical SSRF error at top level The threats handler aggregated sub-handler errors, which wrapped SsrfBlockedError into composite strings. Adds a top-level assertPublicHost gate so SSRF rejection returns the canonical { error: 'Blocked: target resolves to private address' } message before any sub-handler runs. Tightens the threats.test.ts SSRF assertion to verify the canonical error message pattern, preventing regressions where SSRF protection silently stops working in this handler. --- packages/core/src/handlers/threats.test.ts | 3 +-- packages/core/src/handlers/threats.ts | 13 ++++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/core/src/handlers/threats.test.ts b/packages/core/src/handlers/threats.test.ts index 4f77bbb..6e47e2e 100644 --- a/packages/core/src/handlers/threats.test.ts +++ b/packages/core/src/handlers/threats.test.ts @@ -196,8 +196,7 @@ describe('threats handler — SSRF', () => { const result = await threatsHandler(TEST_URL, options); // All four sub-checks fail with SSRF block errors, triggering top-level error - expect(result.error).toBeDefined(); - expect(typeof result.error).toBe('string'); + 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 397c91b..632bab7 100644 --- a/packages/core/src/handlers/threats.ts +++ b/packages/core/src/handlers/threats.ts @@ -3,7 +3,7 @@ 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 } from '../utils/network.js'; +import { SsrfBlockedError, assertPublicHost } from '../utils/network.js'; export interface GoogleSafeBrowsingResult { unsafe?: boolean; @@ -144,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; From 7e0a791931698afeee2f2b42e641e0dd674f9075 Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Sat, 11 Apr 2026 07:11:27 +0200 Subject: [PATCH 06/12] core(screenshot): SSRF gate + remove --no-sandbox from Chromium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds URL validation (protocol allowlist + assertPublicHost) before invoking Chromium. The application-layer guard is the primary SSRF defense — even if Chromium itself follows a redirect, the only URLs it sees are pre-validated. Replaces --no-sandbox with --disable-setuid-sandbox + --disable-dev-shm-usage. The container now requires CAP_SYS_ADMIN + seccomp=unconfined so Chromium can create its user namespace sandbox; this is documented inline. Adds chrome-sandbox setuid fixup in api Dockerfile (conditional on the helper being shipped by the chromium package). --- docker-compose.yml | 7 +++++ packages/api/Dockerfile | 6 ++++ packages/core/src/handlers/screenshot.test.ts | 29 +++++++++++++++++++ packages/core/src/handlers/screenshot.ts | 29 ++++++++++++++++--- 4 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/handlers/screenshot.test.ts diff --git a/docker-compose.yml b/docker-compose.yml index 467d983..24593b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,13 @@ services: interval: 30s timeout: 5s start_period: 10s + # 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_add: + - SYS_ADMIN + security_opt: + - seccomp=unconfined web: build: diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index fb051df..1f49fe9 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 ./ 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, From ffde79d4076e441146a2b0ad4136599a8c9bc351 Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Sat, 11 Apr 2026 07:13:38 +0200 Subject: [PATCH 07/12] api(server): tighten CORS default to false, raise rate limit precision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CORS no longer defaults to wildcard '*' — must be explicitly configured via API_CORS_ORIGIN as a comma-separated allowlist. Empty/unset disables CORS entirely. Rate limit default tightened to 200 / 1 minute (was 100 / 10 minutes, which is roughly the same average but with much larger burst tolerance). Per-route overrides for auth endpoints are added by the pro overlay. --- .env.example | 10 +++--- packages/api/src/config.ts | 4 ++- packages/api/src/server.test.ts | 63 ++++++++++++++++++++++++++++++++- packages/api/src/server.ts | 12 +++++-- 4 files changed, 80 insertions(+), 9 deletions(-) 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/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') { From 17eeb977a2db8b8fba903162e15e96738799aa21 Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Sat, 11 Apr 2026 07:33:33 +0200 Subject: [PATCH 08/12] web(nginx): add strict CSP and security headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and an opt-in HSTS header (gated by HSTS_HEADER env, empty by default for non-TLS deployments). CSP allows 'unsafe-inline' on style-src only — required for Tailwind's atomic class system. script-src is strict 'self', which blocks injected inline JS even if XSS surfaces appear in future. --- packages/web/Dockerfile | 3 +++ packages/web/nginx.conf.template | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/web/Dockerfile b/packages/web/Dockerfile index 72e503d..153d5bf 100644 --- a/packages/web/Dockerfile +++ b/packages/web/Dockerfile @@ -39,5 +39,8 @@ 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 +# 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="" EXPOSE 80 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; From 8d678030ab75e0890b91e8557f6fe7909504c848 Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Sat, 11 Apr 2026 07:47:04 +0200 Subject: [PATCH 09/12] containers: run as non-root, drop all caps, read-only rootfs api: USER 10001, mkdir/chown /app and /home/app, cap_drop ALL, cap_add SYS_ADMIN only for Chromium namespace sandbox, read-only rootfs with tmpfs for /tmp and /home/app/.cache. web: nginx user (UID 101), rebind listen to 8080 (unprivileged), update compose port mapping accordingly, cap_drop ALL, read-only rootfs with tmpfs for /var/cache/nginx and /var/run. cli: USER 10001 (alpine), cap_drop ALL. All three images now reject privilege escalation via no-new-privileges. --- docker-compose.yml | 25 ++++++++++++++++++++++++- packages/api/Dockerfile | 6 ++++++ packages/cli/Dockerfile | 4 ++++ packages/web/Dockerfile | 9 ++++++++- 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 24593b2..29cb07e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,25 +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: @@ -46,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/packages/api/Dockerfile b/packages/api/Dockerfile index 1f49fe9..df38ea1 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -75,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/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/web/Dockerfile b/packages/web/Dockerfile index 153d5bf..a1abbd9 100644 --- a/packages/web/Dockerfile +++ b/packages/web/Dockerfile @@ -43,4 +43,11 @@ ENV API_UPSTREAM=http://api:3000 # the deployment is behind TLS; default empty string emits a no-op header. ENV HSTS_HEADER="" -EXPOSE 80 +# 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 From 8bf858100dcba7b5a07d5148f09f632c723e7687 Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Sat, 11 Apr 2026 07:48:49 +0200 Subject: [PATCH 10/12] ci: sign release images with cosign (keyless OIDC) + attach SBOM Adds id-token: write permission required by cosign keyless OIDC. After the existing docker push step, the workflow now: - Signs each pushed image (api, web, cli) with cosign keyless using the GitHub Actions OIDC identity. Signatures are stored in the GHCR signature mirror and verifiable via cosign verify --certificate-identity. - Generates an SPDX-JSON SBOM via syft for each image. - Attaches the SBOM artifact to the GitHub release. Existing Trivy scans remain in the docker-build job. --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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 From d4f59c474ad724aa157ec7b06053c9bd2b5f8cf5 Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Sat, 11 Apr 2026 07:52:12 +0200 Subject: [PATCH 11/12] release: v1.2.0 Bumps all packages to 1.2.0 and writes the v1.2.0 release notes documenting the security hardening changes. Adds a SECURITY section to the README pointing operators at the new defaults and verification instructions for cosign-signed images. --- .releases/1.2.0.md | 80 ++++++++++++++++++++++++++++++++++++ README.md | 23 +++++++++++ package-lock.json | 14 +++---- package.json | 2 +- packages/api/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/static/package.json | 2 +- packages/web/package.json | 2 +- 9 files changed, 116 insertions(+), 13 deletions(-) create mode 100644 .releases/1.2.0.md 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/package-lock.json b/package-lock.json index aae5e98..1d3eab1 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/*" @@ -10284,7 +10284,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 +10312,7 @@ }, "packages/cli": { "name": "@recon-web/cli", - "version": "1.1.1", + "version": "1.2.0", "dependencies": { "@recon-web/core": "*", "bcryptjs": "^3.0.3", @@ -10334,7 +10334,7 @@ }, "packages/core": { "name": "@recon-web/core", - "version": "1.1.1", + "version": "1.2.0", "dependencies": { "axios": "^1.7.0", "cheerio": "^1.0.0", @@ -10367,14 +10367,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..cb78149 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": [ 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/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..09c3fc0 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", 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/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": { From d521e7a9f5ac7b2fe1195c7e820e4b65ec50f721 Mon Sep 17 00:00:00 2001 From: Bruno Pavelja Date: Sat, 11 Apr 2026 08:27:15 +0200 Subject: [PATCH 12/12] deps: bump axios to ^1.15.0 and override basic-ftp to ^5.2.2 Resolves: - axios CVE-2025-62718 (CRITICAL) - axios CVE-2026-40175 (CRITICAL) - basic-ftp GHSA-6v7q-wjvx-w8wg (HIGH CRLF injection) Unblocks the v1.2.0 release pipeline which the Trivy filesystem scan was rejecting on these three advisories. --- package-lock.json | 678 +++++++++++++++++++++---------------- package.json | 3 + packages/core/package.json | 2 +- 3 files changed, 387 insertions(+), 296 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1d3eab1..6abc1dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" @@ -10336,7 +10424,7 @@ "name": "@recon-web/core", "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", diff --git a/package.json b/package.json index cb78149..af50045 100644 --- a/package.json +++ b/package.json @@ -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/core/package.json b/packages/core/package.json index 09c3fc0..5b2b58d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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",