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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions app/server/utils/analytics-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
47 changes: 47 additions & 0 deletions app/test/analytics-config-trusted-proxies.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
2 changes: 1 addition & 1 deletion docs/production-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
8 changes: 8 additions & 0 deletions k8s/base/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading