diff --git a/app/.env.example b/app/.env.example index 4c337ce..9cf6641 100644 --- a/app/.env.example +++ b/app/.env.example @@ -163,6 +163,8 @@ ANALYTICS_RESPECT_DNT="true" # the socket peer. Set to your reverse-proxy / ingress / CDN edge ranges in # production. Example for a Kubernetes pod CIDR + private nets: # ANALYTICS_TRUSTED_PROXIES="10.0.0.0/8,172.16.0.0/12,fc00::/7" +# NOTE: on a Nuxt-runtime (production) deploy this var is read only at build +# time — at runtime use the NUXT_-prefixed twin NUXT_ANALYTICS_TRUSTED_PROXIES. ANALYTICS_TRUSTED_PROXIES="" # Retention (days). Raw traffic events pruned past retention; rollups kept longer. diff --git a/app/server/utils/analytics-config.ts b/app/server/utils/analytics-config.ts index 65262b5..609c47c 100644 --- a/app/server/utils/analytics-config.ts +++ b/app/server/utils/analytics-config.ts @@ -101,9 +101,11 @@ export function getAnalyticsConfig(): AnalyticsConfig { rateLimitEnabled: raw.rateLimitEnabled !== false, fuzzingEnabled: raw.fuzzingEnabled !== false, ipSalt: String(raw.ipSalt || "adla-analytics-default-salt"), - trustedProxies: Array.isArray(raw.trustedProxies) - ? (raw.trustedProxies as unknown[]).map((p) => String(p).trim()).filter(Boolean) - : [], + trustedProxies: (Array.isArray(raw.trustedProxies) + ? (raw.trustedProxies as unknown[]).map((p) => String(p)) + : String(raw.trustedProxies || "").split(",")) + .map((p) => p.trim()) + .filter(Boolean), respectDnt: raw.respectDnt !== false, retentionDays: Number(raw.retentionDays) || 30, rollupRetentionDays: Number(raw.rollupRetentionDays) || 365, diff --git a/app/test/analytics-config-trusted-proxies.test.ts b/app/test/analytics-config-trusted-proxies.test.ts new file mode 100644 index 0000000..f554a4a --- /dev/null +++ b/app/test/analytics-config-trusted-proxies.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getAnalyticsConfig, + resetAnalyticsConfigCache, +} from "../server/utils/analytics-config"; +import { TEST_ANALYTICS_CONFIG } from "./setup"; + +/** + * `runtimeConfig.analytics.trustedProxies` is an array at build time, but a + * runtime `NUXT_ANALYTICS_TRUSTED_PROXIES` override (the form used in k8s) + * arrives as a comma-separated STRING. `getAnalyticsConfig()` must coerce both + * shapes; otherwise the runtime override silently drops back to `[]` and the + * app records the ingress pod IP instead of the real client IP. + */ +function withTrustedProxies(value: unknown): string[] { + vi.stubGlobal("useRuntimeConfig", () => ({ + analytics: { ...TEST_ANALYTICS_CONFIG, trustedProxies: value }, + })); + resetAnalyticsConfigCache(); + return getAnalyticsConfig().trustedProxies; +} + +describe("getAnalyticsConfig trustedProxies coercion", () => { + afterEach(() => { + // Restore the default global stub + drop the memo so later tests see config. + vi.stubGlobal("useRuntimeConfig", () => ({ analytics: TEST_ANALYTICS_CONFIG })); + resetAnalyticsConfigCache(); + }); + + it("parses a comma-separated string (runtime NUXT_ override form)", () => { + expect( + withTrustedProxies("10.0.0.0/8, 172.16.0.0/12 ,192.168.0.0/16,fc00::/7"), + ).toEqual(["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"]); + }); + + it("still accepts an array (build-time baked form)", () => { + expect(withTrustedProxies(["10.0.0.0/8", " 172.16.0.0/12 "])).toEqual([ + "10.0.0.0/8", + "172.16.0.0/12", + ]); + }); + + it("yields an empty list for empty string / undefined (trust nothing)", () => { + expect(withTrustedProxies("")).toEqual([]); + expect(withTrustedProxies(undefined)).toEqual([]); + }); +}); diff --git a/docs/production-checklist.md b/docs/production-checklist.md index 1b20b6c..733dfb3 100644 --- a/docs/production-checklist.md +++ b/docs/production-checklist.md @@ -32,7 +32,7 @@ These have working defaults; tune for the deployment shape. | Variable | Default | When to override | | --- | --- | --- | -| `ANALYTICS_TRUSTED_PROXIES` | `""` (trust no forwarding headers) | Set to the CIDRs of your ingress / proxy / CDN edges — otherwise rate limiting buckets every request under the proxy's IP and audit-log `ipAddress` records the proxy. Example for a K8s cluster with private nets: `"10.0.0.0/8,172.16.0.0/12,fc00::/7"`. | +| `ANALYTICS_TRUSTED_PROXIES` | `""` (trust no forwarding headers) | Set to the CIDRs of your ingress / proxy / CDN edges — otherwise rate limiting buckets every request under the proxy's IP and audit-log `ipAddress` records the proxy. Example for a K8s cluster with private nets: `"10.0.0.0/8,172.16.0.0/12,fc00::/7"`. **On Nuxt-runtime (production) deploys (k8s), set the `NUXT_`-prefixed name `NUXT_ANALYTICS_TRUSTED_PROXIES`** — the plain name is read only at build time and is ignored at runtime (see the configmap naming contract). Also confirm the real client IP actually reaches the proxy: an ingress-nginx behind a cloud LoadBalancer needs `externalTrafficPolicy: Local` (or proxy-protocol) on its Service, otherwise the source IP is SNAT'd to a node IP before nginx writes `X-Forwarded-For`. | | `MINIO_USE_SSL` | `false` | `true` whenever the Nuxt-to-MinIO link crosses a network segment. | | `SECURITY_CSP_ENFORCE` | `false` (CSP ships report-only) | Flip to `true` once browser DevTools shows no CSP violations on production traffic for ~24h. | | `REDIS_URL` | unset → in-memory analytics store | Always set in multi-instance deploys — the in-memory fallback works per-pod only and the rate limiter's local fail-closed fallback (H-1) is much tighter than the normal limits. | diff --git a/k8s/base/configmap.yaml b/k8s/base/configmap.yaml index 45f0c14..8a6f225 100644 --- a/k8s/base/configmap.yaml +++ b/k8s/base/configmap.yaml @@ -59,6 +59,14 @@ data: # (e.g. uploadPer5Min → NUXT_ANALYTICS_RL_UPLOAD_PER5_MIN). NUXT_ANALYTICS_STORAGE_DRIVER: "redis" + # Honor X-Forwarded-For / X-Real-IP from the in-cluster ingress-nginx proxy so + # Web Analytics / audit logs / rate-limits record the real client IP, not the + # ingress pod IP. NUXT_-prefixed so it overrides runtimeConfig at runtime + # (plain ANALYTICS_TRUSTED_PROXIES is read only at build time → ignored here). + # Broad private ranges are safe: the NetworkPolicy already limits inbound to + # app pods to the ingress-nginx pods only, so nothing else can spoof XFF. + NUXT_ANALYTICS_TRUSTED_PROXIES: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7" + ANALYTICS_ENABLED: "true" ANALYTICS_CAPTURE_ENABLED: "true" ANALYTICS_ABUSE_ENABLED: "true"