security(web): sanitize runtime map config
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0 --port 20301",
|
"dev": "vite --host 0.0.0.0 --port 20301",
|
||||||
"build": "tsc -b && vite build && node scripts/verify-build.mjs",
|
"build": "tsc -b && vite build && node scripts/prepare-dist.mjs && node scripts/verify-build.mjs",
|
||||||
"test": "vitest run --exclude src/test/App.test.tsx",
|
"test": "vitest run --exclude src/test/App.test.tsx",
|
||||||
"test:legacy": "vitest run src/test/App.test.tsx",
|
"test:legacy": "vitest run src/test/App.test.tsx",
|
||||||
"test:all": "vitest run"
|
"test:all": "vitest run"
|
||||||
|
|||||||
12
vehicle-data-platform/apps/web/scripts/prepare-dist.mjs
Normal file
12
vehicle-data-platform/apps/web/scripts/prepare-dist.mjs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { copyFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const source = resolve(process.cwd(), 'public/app-config.example.js');
|
||||||
|
const destination = resolve(process.cwd(), 'dist/app-config.js');
|
||||||
|
|
||||||
|
// Vite copies public/app-config.js verbatim. That file is intentionally ignored
|
||||||
|
// because developers may keep local AMap credentials in it. Never let those
|
||||||
|
// machine-local values enter a production archive; the API renders the real
|
||||||
|
// runtime configuration at /app-config.js on the server.
|
||||||
|
copyFileSync(source, destination);
|
||||||
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { readdirSync, statSync } from 'node:fs';
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
const assetsDirectory = resolve(process.cwd(), 'dist/assets');
|
const assetsDirectory = resolve(process.cwd(), 'dist/assets');
|
||||||
const assets = readdirSync(assetsDirectory);
|
const assets = readdirSync(assetsDirectory);
|
||||||
const excelAssets = assets.filter((name) => /^exceljs(?:\.min)?-[\w-]+\.js$/.test(name));
|
const excelAssets = assets.filter((name) => /^exceljs(?:\.min)?-[\w-]+\.js$/.test(name));
|
||||||
const mileageWorkers = assets.filter((name) => /^mileageExport\.worker-[\w-]+\.js$/.test(name));
|
const mileageWorkers = assets.filter((name) => /^mileageExport\.worker-[\w-]+\.js$/.test(name));
|
||||||
|
const runtimeConfig = readFileSync(resolve(process.cwd(), 'dist/app-config.js'), 'utf8');
|
||||||
|
const safeRuntimeTemplate = readFileSync(resolve(process.cwd(), 'public/app-config.example.js'), 'utf8');
|
||||||
|
|
||||||
if (excelAssets.length !== 1) {
|
if (excelAssets.length !== 1) {
|
||||||
throw new Error(`production build must contain exactly one ExcelJS asset, found ${excelAssets.length}: ${excelAssets.join(', ') || 'none'}`);
|
throw new Error(`production build must contain exactly one ExcelJS asset, found ${excelAssets.length}: ${excelAssets.join(', ') || 'none'}`);
|
||||||
@@ -12,7 +14,14 @@ if (excelAssets.length !== 1) {
|
|||||||
if (mileageWorkers.length !== 1) {
|
if (mileageWorkers.length !== 1) {
|
||||||
throw new Error(`production build must contain exactly one mileage export worker, found ${mileageWorkers.length}: ${mileageWorkers.join(', ') || 'none'}`);
|
throw new Error(`production build must contain exactly one mileage export worker, found ${mileageWorkers.length}: ${mileageWorkers.join(', ') || 'none'}`);
|
||||||
}
|
}
|
||||||
|
if (runtimeConfig !== safeRuntimeTemplate) {
|
||||||
|
throw new Error('production dist/app-config.js must match the credential-free runtime template');
|
||||||
|
}
|
||||||
|
const configuredSecurityCode = runtimeConfig.match(/["']?amapSecurityJsCode["']?\s*:\s*(["'])(.*?)\1/s)?.[2].trim();
|
||||||
|
if (configuredSecurityCode) {
|
||||||
|
throw new Error('production dist/app-config.js must not contain an AMap security code');
|
||||||
|
}
|
||||||
|
|
||||||
const excelBytes = statSync(resolve(assetsDirectory, excelAssets[0])).size;
|
const excelBytes = statSync(resolve(assetsDirectory, excelAssets[0])).size;
|
||||||
const workerBytes = statSync(resolve(assetsDirectory, mileageWorkers[0])).size;
|
const workerBytes = statSync(resolve(assetsDirectory, mileageWorkers[0])).size;
|
||||||
process.stdout.write(`web_build_gate=ok exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes}\n`);
|
process.stdout.write(`web_build_gate=ok exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes} runtime_config_sanitized=1\n`);
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ fetch_exact / "$WEB_ROOT/index.html" 'root document'
|
|||||||
config_status=$(curl --silent --show-error --max-time "$CURL_TIMEOUT_SEC" --output "$response_file" --write-out '%{http_code}' "$BASE_URL/app-config.js" || true)
|
config_status=$(curl --silent --show-error --max-time "$CURL_TIMEOUT_SEC" --output "$response_file" --write-out '%{http_code}' "$BASE_URL/app-config.js" || true)
|
||||||
test "$config_status" = 200 || fail "app config returned HTTP ${config_status:-000}"
|
test "$config_status" = 200 || fail "app config returned HTTP ${config_status:-000}"
|
||||||
grep -q 'window\.__LINGNIU_APP_CONFIG__' "$response_file" || fail 'app config response does not expose the runtime configuration object'
|
grep -q 'window\.__LINGNIU_APP_CONFIG__' "$response_file" || fail 'app config response does not expose the runtime configuration object'
|
||||||
|
if grep -q '"amapSecurityJsCode"' "$response_file"; then
|
||||||
|
fail 'app config response exposes the server-side AMap security code'
|
||||||
|
fi
|
||||||
|
grep -q '"amapSecurityServiceHost":"/_AMapService"' "$response_file" || fail 'app config response does not enable the server-side AMap proxy'
|
||||||
|
|
||||||
current_count=$(verify_manifest "$CURRENT_MANIFEST" current)
|
current_count=$(verify_manifest "$CURRENT_MANIFEST" current)
|
||||||
compatibility_count=0
|
compatibility_count=0
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ trap cleanup EXIT
|
|||||||
|
|
||||||
mkdir -p "$fixture/web/assets"
|
mkdir -p "$fixture/web/assets"
|
||||||
printf '<!doctype html><div id="root"></div><script src="/assets/new.js"></script>\n' > "$fixture/web/index.html"
|
printf '<!doctype html><div id="root"></div><script src="/assets/new.js"></script>\n' > "$fixture/web/index.html"
|
||||||
printf 'window.__LINGNIU_APP_CONFIG__ = {};\n' > "$fixture/web/app-config.js"
|
printf 'window.__LINGNIU_APP_CONFIG__={"amapSecurityServiceHost":"/_AMapService"};\n' > "$fixture/web/app-config.js"
|
||||||
printf 'console.log("new");\n' > "$fixture/web/assets/new.js"
|
printf 'console.log("new");\n' > "$fixture/web/assets/new.js"
|
||||||
printf 'console.log("old");\n' > "$fixture/web/assets/old.js"
|
printf 'console.log("old");\n' > "$fixture/web/assets/old.js"
|
||||||
printf 'new.js\n' > "$fixture/web/.release-assets"
|
printf 'new.js\n' > "$fixture/web/.release-assets"
|
||||||
@@ -48,6 +48,21 @@ fi
|
|||||||
success_output=$("$SCRIPT_DIR/verify-web-release.sh" "$fixture/web" "http://127.0.0.1:$port" "$fixture/previous-assets")
|
success_output=$("$SCRIPT_DIR/verify-web-release.sh" "$fixture/web" "http://127.0.0.1:$port" "$fixture/previous-assets")
|
||||||
test "$success_output" = 'web_release_smoke=ok current_assets=1 compatibility_assets=1'
|
test "$success_output" = 'web_release_smoke=ok current_assets=1 compatibility_assets=1'
|
||||||
|
|
||||||
|
printf 'window.__LINGNIU_APP_CONFIG__={"amapSecurityJsCode":"must-not-ship"};\n' > "$fixture/served/app-config.js"
|
||||||
|
if "$SCRIPT_DIR/verify-web-release.sh" "$fixture/web" "http://127.0.0.1:$port" "$fixture/previous-assets" > "$fixture/exposed-config.out" 2>&1; then
|
||||||
|
printf 'expected an exposed AMap security code to fail\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -q 'exposes the server-side AMap security code' "$fixture/exposed-config.out"
|
||||||
|
|
||||||
|
printf 'window.__LINGNIU_APP_CONFIG__={};\n' > "$fixture/served/app-config.js"
|
||||||
|
if "$SCRIPT_DIR/verify-web-release.sh" "$fixture/web" "http://127.0.0.1:$port" "$fixture/previous-assets" > "$fixture/missing-proxy.out" 2>&1; then
|
||||||
|
printf 'expected a missing AMap security proxy to fail\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -q 'does not enable the server-side AMap proxy' "$fixture/missing-proxy.out"
|
||||||
|
cp "$fixture/web/app-config.js" "$fixture/served/app-config.js"
|
||||||
|
|
||||||
rm "$fixture/web/assets/old.js"
|
rm "$fixture/web/assets/old.js"
|
||||||
if "$SCRIPT_DIR/verify-web-release.sh" "$fixture/web" "http://127.0.0.1:$port" "$fixture/previous-assets" > "$fixture/missing.out" 2>&1; then
|
if "$SCRIPT_DIR/verify-web-release.sh" "$fixture/web" "http://127.0.0.1:$port" "$fixture/previous-assets" > "$fixture/missing.out" 2>&1; then
|
||||||
printf 'expected a missing compatibility asset to fail\n' >&2
|
printf 'expected a missing compatibility asset to fail\n' >&2
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ ALERT_STREAM_LATENESS_SEC=120
|
|||||||
|
|
||||||
`AMAP_WEB_JS_KEY` is served to the browser through `/app-config.js` so the same static build can be reused across environments. For production, set both `AMAP_SECURITY_JS_CODE` and `AMAP_SECURITY_SERVICE_HOST=/_AMapService`; the API service keeps the security code on the server and proxies AMap requests with `jscode` appended. Only omit `AMAP_SECURITY_SERVICE_HOST` for controlled debugging where exposing `AMAP_SECURITY_JS_CODE` to the browser is acceptable. `AMAP_API_KEY` is reserved for backend-only AMap service APIs such as geocoding, route planning, geofence, or trajectory service integration.
|
`AMAP_WEB_JS_KEY` is served to the browser through `/app-config.js` so the same static build can be reused across environments. For production, set both `AMAP_SECURITY_JS_CODE` and `AMAP_SECURITY_SERVICE_HOST=/_AMapService`; the API service keeps the security code on the server and proxies AMap requests with `jscode` appended. Only omit `AMAP_SECURITY_SERVICE_HOST` for controlled debugging where exposing `AMAP_SECURITY_JS_CODE` to the browser is acceptable. `AMAP_API_KEY` is reserved for backend-only AMap service APIs such as geocoding, route planning, geofence, or trajectory service integration.
|
||||||
|
|
||||||
|
`apps/web/public/app-config.js` is ignored and may contain developer-local values. `pnpm build` always replaces its copied output with `app-config.example.js` before packaging, then verifies an exact byte match. This prevents a local security code from entering a release archive even though production requests are dynamically intercepted by the API. The release smoke gate separately rejects any `/app-config.js` response containing `amapSecurityJsCode` and requires `amapSecurityServiceHost=/_AMapService`.
|
||||||
|
|
||||||
`PLATFORM_RELEASE` is surfaced by `/api/ops/health` at `data.runtime.platformRelease` so operators can confirm which ECS release is currently active after a deployment.
|
`PLATFORM_RELEASE` is surfaced by `/api/ops/health` at `data.runtime.platformRelease` so operators can confirm which ECS release is currently active after a deployment.
|
||||||
|
|
||||||
All three platform systemd units first load `/opt/lingniu-go-native/env/base.env` for the existing MySQL, Redis, TDengine and Kafka connection settings, then load `platform.env` for platform-specific overrides. `DATA_MODE=production` is mandatory on ECS: a missing or unreachable MySQL connection returns `DATA_STORE_UNAVAILABLE` instead of silently serving demonstration data. Use `DATA_MODE=mock` only for local development.
|
All three platform systemd units first load `/opt/lingniu-go-native/env/base.env` for the existing MySQL, Redis, TDengine and Kafka connection settings, then load `platform.env` for platform-specific overrides. `DATA_MODE=production` is mandatory on ECS: a missing or unreachable MySQL connection returns `DATA_STORE_UNAVAILABLE` instead of silently serving demonstration data. Use `DATA_MODE=mock` only for local development.
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ Excel export previously produced two independent 940 KB ExcelJS assets because t
|
|||||||
|
|
||||||
The Alert Center previously mounted every tab's data dependencies together: the default event view fetched the metric catalog even though only the rule editor consumes it, while a direct notifications entry fetched both the unread-only collection and the full notification collection. Queries are now gated by the active workspace. Events load events, summary, rule names and the unread badge; rules add the metric catalog only when opened; notifications load one full collection and derive the unread badge from it. This follows TanStack Query's lazy-query `enabled` model and preserves Grafana's separation between alert triage, rule configuration and notification handling: <https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries>, <https://grafana.com/docs/grafana/latest/alerting/>. Request-count tests protect both the default event route and a direct notifications entry.
|
The Alert Center previously mounted every tab's data dependencies together: the default event view fetched the metric catalog even though only the rule editor consumes it, while a direct notifications entry fetched both the unread-only collection and the full notification collection. Queries are now gated by the active workspace. Events load events, summary, rule names and the unread badge; rules add the metric catalog only when opened; notifications load one full collection and derive the unread badge from it. This follows TanStack Query's lazy-query `enabled` model and preserves Grafana's separation between alert triage, rule configuration and notification handling: <https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries>, <https://grafana.com/docs/grafana/latest/alerting/>. Request-count tests protect both the default event route and a direct notifications entry.
|
||||||
|
|
||||||
|
Vite previously copied the ignored developer-local `public/app-config.js` into `dist` unchanged. The API correctly intercepted production `/app-config.js` requests, but a local AMap security code still entered release archives and would become exposed if that interception ever regressed. The production build now overwrites the copied file with the credential-free example and verifies an exact byte match. The ECS release smoke gate independently requires the server-side `/_AMapService` host and rejects the security-code property, so both the artifact and the served runtime surface fail closed. This matches AMap's production guidance, which strongly recommends server proxy forwarding and explicitly classifies browser plaintext configuration as unsafe: <https://lbs.amap.com/api/javascript-api-v2/guide/abc/jscode>.
|
||||||
|
|
||||||
The navigation and progressive-loading direction follows mature observability systems: persistent global controls, collapsible sections and loading only the content needed for the current task. Elastic documents these dashboard interaction and panel-organization patterns here: <https://www.elastic.co/docs/explore-analyze/dashboards/using> and <https://www.elastic.co/docs/explore-analyze/dashboards/arrange-panels>.
|
The navigation and progressive-loading direction follows mature observability systems: persistent global controls, collapsible sections and loading only the content needed for the current task. Elastic documents these dashboard interaction and panel-organization patterns here: <https://www.elastic.co/docs/explore-analyze/dashboards/using> and <https://www.elastic.co/docs/explore-analyze/dashboards/arrange-panels>.
|
||||||
|
|
||||||
### Remaining audit queue
|
### Remaining audit queue
|
||||||
|
|||||||
Reference in New Issue
Block a user