From 7f7b8ef0c01d5525162c8af629a94dd5de62b55d Mon Sep 17 00:00:00 2001 From: llfzzz Date: Thu, 9 Jul 2026 12:36:25 +0800 Subject: [PATCH] fix(security): close Sentinel 0.0.0.0:8719 exposure; add load-test tooling (S34) Sentinel's transport/command-center listener was binding 0.0.0.0:8719 despite Sentinel being completely unused (confirmed via bytecode inspection - zero @SentinelResource/flow-rule usage anywhere in any service). Disable it via spring.cloud.sentinel.enabled=false in the shared systemd unit template (config-only, no rebuild required; applies to all 14 services on next restart). Also, alongside redeploying order-service/payment-sim-service to pick up the already-committed S33 source (their jars had gone stale - see AGENTS.md S34 entry for the full writeup): - Extend scripts/check-deployment.sh with S33 internal-only-path and driver-review RBAC re-verification, plus an INFO-only baseline of the two documented, deliberately-deferred security gaps in docs/security.md. - Add docs/load-testing.md: a static, config-derived capacity analysis (no synthetic load generated against the production host, which is already structurally memory-oversubscribed at idle). - Add scripts/loadtest/ (k6 scripts modeling the existing demo-smoke.sh flow plus a rate-limit boundary probe) - guarded to refuse running against the known production host. - Ignore local jar backups created during the redeploy. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + AGENTS.md | 9 ++ deploy/systemd/o2o@.service | 1 + docs/load-testing.md | 154 +++++++++++++++++++++++ scripts/check-deployment.sh | 71 ++++++++++- scripts/loadtest/booking-flow.js | 161 ++++++++++++++++++++++++ scripts/loadtest/lib/api.js | 75 +++++++++++ scripts/loadtest/rate-limit-boundary.js | 104 +++++++++++++++ 8 files changed, 575 insertions(+), 1 deletion(-) create mode 100644 docs/load-testing.md create mode 100644 scripts/loadtest/booking-flow.js create mode 100644 scripts/loadtest/lib/api.js create mode 100644 scripts/loadtest/rate-limit-boundary.js diff --git a/.gitignore b/.gitignore index 056313f..5532a05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store target/ +deploy/jar-backups/ node_modules/ dist/ .vite/ diff --git a/AGENTS.md b/AGENTS.md index a0a098e..0f99762 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,6 +195,15 @@ docs/ PRD、架构、API、运维、ADR、产品设计 - **部署**:**本次安全加固上线只需重建并重启 `gateway-service` 单个服务**(内部 Feign 走直连不受影响,无需动其余 13 个服务);死代码清理与恒等三元属源码整洁,随下次 order/payment-sim 全量重部署生效,其间网关 404 已覆盖线上风险。**尚未 `git push`(本机无 push 凭据)**,需用户从有凭据的机器推送。 - **未修(有意,已在 `docs/security.md` Known gaps 记录为建议)**:`GET /api/orders/{id}`、`GET /api/orders/{id}/review`、`GET /api/trips/{id}` 的读侧 IDOR(随机 UUID 不可枚举,改动触及 H5 热路径,建议单独一轮加 owner/operator scoping 与测试);`POST /api/trips` 的 `driverId` 取自 body 而非绑定登录主体、且未在发布时要求 driver 能力(建议改为从 `X-User-Id` 解析 + 发布时校验 driver 准入)。 +**S34(2026-07-09,部署漂移修复 + Sentinel 端口加固 + 压测评估,本机改动,尚未 commit/push):** 补齐 S33 遗留的部署漂移,新增一处此前未发现的端口暴露修复,并交付静态容量评估与可复用压测脚本—— + +- **部署漂移修复**:确认 `order-service`/`payment-sim-service` 的线上 jar 早于 `26bba6b`(`jar tf` 证实 payment-sim-service 仍打包已删除的 `PaymentSimulationController/Service/Repository`,order-service 仍含已删除的 `/timeout` 方法),根因是本文件已记录的「Maven 增量坑」。修复:先 `systemctl stop` 目标服务再 `./mvnw -pl -am clean package -DskipTests`(避免 jar 被运行中 JVM 占用),逐个重启 + `/actuator/health` 健康门禁,静态解包校验死代码已清除(不能靠网关 curl 验证——网关层已统一 404 掉这些内部路径,不管后端 jar 是否过期)。两次重建 + 重启均在 25 秒内健康完成(对比 2026-07-08 网关单独重启触发的 ~20 分钟崩溃重启循环),因为「先停服务再构建」同时释放了该服务的 cgroup 内存额度、也从根上避开了 jar 被占用的问题。 +- **新发现并修复:Sentinel 端口 `0.0.0.0` 暴露**:gateway-service 的 Sentinel 传输端口 `8719` 监听在所有网卡而非仅 `127.0.0.1`(该版本 Sentinel 的 `ServerSocket(port, backlog)` 无绑定地址可配)。字节码级确认 Sentinel 在本项目零功能使用(无 `@SentinelResource`/规则数据源),且该暴露不是网关独有——14 个服务的 jar 里都潜伏同样的能力,只是网关最先因为流量触发了它的懒加载 command center。修复:在共享的 `deploy/systemd/o2o@.service` 模板加一行 `Environment="SPRING_CLOUD_SENTINEL_ENABLED=false"`(配置级生效,无需重新编译),随本次 order/payment-sim 重启顺带下发,另重启 gateway-service 专门关闭当前已开的 8719(`ss -tlnp` 确认关闭)。其余 11 个服务的配置已就位但要等各自下次自然重启才生效(本次未强制重启,因为它们目前都没有实际打开该端口);永久修复(`backend/pom.xml` 排除 `sentinel-transport-simple-http`,覆盖全部 14 个服务)留作后续、需要全量重建,本次的配置级修复已关闭实际风险。 +- **`scripts/check-deployment.sh` 新增 3 节**:内部专用路径 404 复核(S33 网关拦截仍生效)、司机审核 RBAC 复核(operator 200 / rider 403)、已知安全缺口基线(`docs/security.md` Known gaps 里读侧 IDOR 与 trip publish 身份绑定两条,用新增的 `info()` 桶记录当前行为、不计入 `FAILS`——确认两条均未变化,仍是有意保留)。 +- **压测评估(未对本机生成任何并发流量)**:主机在空闲基线下已处于内存超卖状态(14 服务 `MemoryMax` 硬上限总和 3640MB + 5 个中间件容器 1152MB = 4792MB,超过 3.4GB 物理内存约 38%),这解释了为什么「重启」而非「持续并发」才是本机真正的风险来源。新增 `docs/load-testing.md` 记录完整静态容量分析(Hikari 每服务 2 连接的排队上限、MySQL 40 连接对 28 个 Hikari 连接的余量、网关限流 20/60s 与 120/60s 的吞吐天花板)。新增 `scripts/loadtest/`(`lib/api.js` + `booking-flow.js` + `rate-limit-boundary.js`,k6 脚本)——每个脚本 `setup()` 里都有双重防呆(生产域名黑名单 + 需要显式 `I_UNDERSTAND_THIS_IS_NOT_FOR_PROD=yes`),本次会话未在本机执行,供未来指向 staging/本地栈使用。 +- **验证**:`scripts/check-deployment.sh` 与 `scripts/demo-smoke.sh` 均在重新部署后针对线上跑通,`ALL CHECKS PASSED` / `FAILS=0`;全程 `free`/`vmstat`/`journalctl` 监控,无一次崩溃或换页恶化。 +- **尚未 `git commit`**(改动:`deploy/systemd/o2o@.service`、`scripts/check-deployment.sh`,新增 `docs/load-testing.md` 与 `scripts/loadtest/*`),留给用户决定是否提交。 + ## 已完成 — Demo Mode 阶段详情 以下每一项都已经过对应模块的单元测试验证、`git commit` 到 `main` 并 `git push` 完成,可用 `git log --oneline` 核对提交哈希。 diff --git a/deploy/systemd/o2o@.service b/deploy/systemd/o2o@.service index 95ef612..ba286d4 100644 --- a/deploy/systemd/o2o@.service +++ b/deploy/systemd/o2o@.service @@ -15,6 +15,7 @@ Environment="SPRING_CLOUD_SERVICE_REGISTRY_AUTO_REGISTRATION_ENABLED=false" Environment="SERVER_ADDRESS=127.0.0.1" Environment="SPRING_CLOUD_NACOS_DISCOVERY_IP=127.0.0.1" Environment="MANAGEMENT_HEALTH_SENTINEL_ENABLED=false" +Environment="SPRING_CLOUD_SENTINEL_ENABLED=false" Environment="MANAGEMENT_HEALTH_DISCOVERY_ENABLED=false" Environment="MANAGEMENT_HEALTH_DEFAULTS_ENABLED=false" Environment="MANAGEMENT_HEALTH_DB_ENABLED=false" diff --git a/docs/load-testing.md b/docs/load-testing.md new file mode 100644 index 0000000..c5c8dca --- /dev/null +++ b/docs/load-testing.md @@ -0,0 +1,154 @@ +# Load Testing: Capacity Analysis & Deployment Verification (2026-07-09) + +## Context + +This project's own `docs/demo-mode.md` states that "full observability and load/pen testing are +intentionally out of scope for this demo round." This document does **not** contradict that — it +records a static, config-derived capacity analysis plus a deployment-drift fix, produced without +generating synthetic concurrent load against the live host, because the host (`/var/www/o2o-Local-Carpooling` +is itself the production box for `woxiangchuanaj.top`) demonstrably cannot absorb it: on 2026-07-08 a +single service restart (rebuilding+restarting `gateway-service` for the S33 commit) triggered a +~20-minute crash/restart loop. Reusable k6 scripts for exercising this system properly are included +(`scripts/loadtest/`), but are explicitly guarded against ever running here — they're meant for a +laptop, staging environment, or CI. + +## What triggered this + +A routine "make sure the project is running correctly" pass found the project was not actually in the +state its own S33 security audit (`26bba6b`) believed it was in: + +- `order-service` and `payment-sim-service` were running jars built *before* `26bba6b`. Confirmed via + `jar tf` — `payment-sim-service`'s jar still contained the deleted + `PaymentSimulationController/Service/Repository` classes, and `order-service` still had the removed + `POST /api/orders/{id}/timeout` method. Root cause: the Maven pitfall already documented in + `AGENTS.md` (~line 517) — a jar isn't repackaged if held open by its own running JVM. +- gateway-service's Sentinel transport port **8719 was bound to `0.0.0.0`**, not loopback. Bytecode + inspection confirmed Sentinel is 100% unused (zero `@SentinelResource`/flow-rule usage anywhere) and + that the same dormant exposure is baked into all 14 services' jars — gateway had simply served enough + traffic to be the first to lazily trigger the listener. + +Both were fixed this session (see `AGENTS.md` S34 entry for the full changelog) and verified two ways: +static jar-content inspection (the only reliable proof — `GatewaySecurityFilter` already 404s the +affected paths regardless of the backend jar's actual content, so an HTTP check alone cannot prove a +redeploy landed) and a full pass of `scripts/check-deployment.sh` (extended with 3 new sections) + +`scripts/demo-smoke.sh`, both green, `FAILS=0`. + +## Static capacity analysis + +All figures below come directly from live, already-deployed configuration — no traffic was generated to +produce them. + +### Memory is the single load-bearing bottleneck + +| | Per unit | × count | Total | +|---|---|---|---| +| Backend JVM `MemoryMax` (systemd hard ceiling) | 260M | × 14 services | 3640 MiB | +| Middleware container limits (mysql/redis/rabbitmq/mongodb/minio) | — | 5 containers | 1152 MiB | +| **Combined hard-ceiling sum** | | | **4792 MiB** | +| Total physical RAM | | | 3482 MiB (3.4 GiB) | + +**The configured hard ceilings alone already exceed total physical RAM by ~38%, before any request +traffic exists.** Even the soft `MemoryHigh` sum (14×180M + 1152M = 3672 MiB) is ~5.5% over budget. This +is why the host runs in permanent memory oversubscription *by design*, even fully idle (observed +baseline: ~115-150MB free, ~2.0-2.3GB already in swap). It directly explains why a *restart* — a +transient JIT/classloading/connection-pool-warmup spike landing on a baseline with ~0 headroom — is what +previously cascaded into a crash loop, not sustained request concurrency. This matches +`docs/operations.md`'s own stated requirement almost exactly: ≥8GB *available* gives roughly 3.4GB of +genuine headroom above the 4792MB hard-ceiling sum, a sane margin for OS/cache/burst. + +**This session's redeploy validated the mitigation**: stopping each target service *before* rebuilding +(rather than rebuilding a jar held open by its own running JVM) both sidesteps the Maven repackaging bug +and frees ~180-260MB of headroom before the build runs. Result: `order-service` reached healthy in ~25s +and `payment-sim-service` in ~24s — compare to the 205-267s (and an outright crash loop) seen on +2026-07-08 doing it the other way. + +### Per-service concurrency ceiling: Hikari, not Tomcat + +- `spring.datasource.hikari.maximum-pool-size=2`, uniform across all 14 services (no per-service + override raises it — `deploy/systemd/o2o@.service`). +- A 3rd simultaneous DB-touching request to the *same* service queues for up to the fixed + `connection-timeout=120000` (2 minutes) before failing — a tight ceiling reached at just 3-4 + simultaneous requests to one service, and the long queue tolerance means overload shows up as severe + latency, not fast, cheap failure. +- Tomcat's default 200-thread cap is never the real constraint: `-Xmx96m -XX:MaxMetaspaceSize=96m + -XX:ReservedCodeCacheSize=24m` already claims 216 of the 260MB hard `MemoryMax`, leaving ~44MB for + thread stacks/buffers/native overhead — a cgroup OOM-kill (`SIGKILL`) is the realistic failure mode + long before thread count matters. + +### MySQL connection headroom: comfortable, not the bottleneck + +14 services × 2 max Hikari connections = 28 possible concurrent app connections, vs. +`--max-connections=40` → 12 (30%) of headroom in the worst case. Actual idle usage is much lower since +only `order-service`/`trip-service` set `minimum-idle=1`; the other 12 default to `minimum-idle=0`. + +### Gateway rate limits (a deliberate control, not a bug — but a real throughput ceiling regardless) + +- `/api/auth/**`: **20 requests / 60s**, keyed by client IP. +- All other `/api/**`: **120 requests / 60s**, keyed by authenticated user id. +- Fixed, wall-clock-epoch-aligned windows (not sliding, not token-bucket) — + `backend/gateway-service/src/main/resources/application.yml`. +- Realistic max sustained per-identity throughput: ~2 req/s general API, ~0.33 req/s on auth endpoints, + regardless of backend capacity. + +### CPU: secondary constraint + +2 total vCPUs; each JVM pinned `-XX:ActiveProcessorCount=1`; middleware reserves ~1.2 cores of soft quota +between the 5 containers, leaving under 1 core of headroom for 14 JVMs to contend over. Real, but this +throttles rather than kills — memory is the primary risk on this host, not CPU. + +### Bottom line + +This architecture's constraints (Hikari pool of 2, rate limits) are tight but *graceful* — they queue or +429. The actual observed failure mode is *not* "too many concurrent requests," it's "a process lifecycle +event is a transient memory spike landing on a baseline with zero headroom." That is exactly why no +synthetic concurrent load was generated against this host as part of this work, and why the redeploy +runbook used here (stop-before-rebuild, one service at a time, health-gated, with explicit abort +criteria) is the correct shape of mitigation for this specific risk. + +## Verified this session (not a load test — correctness + security-boundary checks only) + +`scripts/check-deployment.sh` (extended with 3 new sections) and `scripts/demo-smoke.sh`, both run +against the live host post-redeploy, both fully green: + +- Admin listing endpoints (S29+), unmapped-path 404 semantics, cold/warm latency snapshot — all as + before, all passing (cold/warm gap for `/api/trips` was 0.85s → 0.04s post-redeploy, well below the + multi-second gaps documented during the 2026-07-07 host-paging incident). +- **New — S33 re-verification**: all 7 internal-only paths (`POST /api/users`, `GET /api/users/{id}`, + order pay/timeout, trip seat-locks, legacy payment simulations) still correctly 404 externally; + driver-review RBAC still correctly gated (operator 200 / rider 403 on both the list and approve + actions). +- **New — known-gap baseline** (INFO-only, not a failure — these are documented, deliberately deferred + in `docs/security.md`'s Known Gaps, not new findings): confirmed unchanged — `GET /api/orders/{id}` + and `GET /api/trips/{id}` are still readable cross-user (read-side IDOR, mitigated only by + unguessable UUIDs), and `POST /api/trips` still trusts a body-supplied `driverId` rather than binding + to the authenticated principal. Recorded as a live baseline rather than a stale doc claim; not touched + this session. +- Full `demo-smoke.sh` 13-step flow (login → publish → search → book → payment intent → signed + callback → SEAT_LOCKED → identity verification → complete → review + duplicate-409 → cancel → + negative-authz 403): `FAILS=0`. + +## Reusable load-test scripts (`scripts/loadtest/`) — not run against this host + +- `lib/api.js` — shared login/demo-inbox/operator-session helpers (JS port of `demo-smoke.sh`'s + `login()`). +- `booking-flow.js` — the full `demo-smoke.sh` flow as k6 checks, parameterized + (`VUS`/`DURATION`/`ACCOUNT_SEED`), conservative defaults. +- `rate-limit-boundary.js` — single-VU, single-iteration boundary probe for the 20/60s and 120/60s + limits, window-aligned so a burst can't straddle two fixed windows. +- Every script refuses to run (in `setup()`) unless `TARGET_BASE_URL` avoids a known-production-hostname + denylist **and** `I_UNDERSTAND_THIS_IS_NOT_FOR_PROD=yes` is explicitly set. See the header comment in + each file for prerequisites (a `demo`-profile target is required — the endpoints used 404 outside it). + +## Recommendations (not executed this session) + +- **Permanent Sentinel fix**: exclude `sentinel-transport-simple-http` in root `backend/pom.xml`'s + `` for `spring-cloud-starter-alibaba-sentinel` (covers all 14 services in one + diff, since none pin their own version). Requires rebuilding all 14 — deferred because the config-only + fix already applied (`SPRING_CLOUD_SENTINEL_ENABLED=false`) closes the actual exposure; the 11 services + that don't currently have 8719 open pick up the same config fix at their next natural restart. +- If real concurrent-load testing is ever wanted, it needs a target other than this host — a staging + environment sized per `docs/operations.md`'s own ≥8GB-available guidance, or a local Docker Compose + stack. `scripts/loadtest/booking-flow.js` is ready for that the moment such a target exists. +- The two known-gap security items (read-side IDOR, trip `driverId` trust) remain intentionally + deferred per `docs/security.md` — this session only re-confirmed current behavior, it did not change + the triage decision. diff --git a/scripts/check-deployment.sh b/scripts/check-deployment.sh index df592b5..9478238 100755 --- a/scripts/check-deployment.sh +++ b/scripts/check-deployment.sh @@ -12,7 +12,11 @@ # 3. unmapped paths answer 404 NOT_FOUND (not 500 INTERNAL_ERROR) — pins the common # GlobalApiExceptionHandler fix; # 4. a latency snapshot of the interactive endpoints, called twice: a large cold->warm gap -# means service processes are being paged out (host memory pressure), not a code problem. +# means service processes are being paged out (host memory pressure), not a code problem; +# 5-6. the S33 gateway hardening (internal-only 404s + driver-review RBAC) is still correctly +# enforced — added 2026-07-09 alongside an order-service/payment-sim-service redeploy; +# 7. an INFO-only baseline of the two documented, deliberately-deferred security gaps +# (docs/security.md Known gaps) — records current behavior, does not fail the run. set -u BASE="${1:-http://127.0.0.1:8080}" FAILS=0 @@ -27,6 +31,16 @@ except Exception: print('')"; } ok(){ echo " PASS: $1"; } bad(){ echo " FAIL: $1"; FAILS=$((FAILS+1)); } +info(){ echo " INFO: $1"; } + +# $1=phone -> echoes "TOKEN|USERID|ROLES" (same shape as demo-smoke.sh's login()) +login() { + local phone=$1 + CURL -X POST "$BASE/api/auth/sms-code" -H 'Content-Type: application/json' -d "{\"phone\":\"$phone\"}" >/dev/null + local code; code=$(CURL "$BASE/api/auth/sms-code/demo-inbox?phone=$phone" | j "['code']") + local resp; resp=$(CURL -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' -d "{\"phone\":\"$phone\",\"code\":\"$code\"}") + echo "$(echo "$resp" | j "['accessToken']")|$(echo "$resp" | j "['user']['userId']")|$(echo "$resp" | j "['user']['roles']")" +} echo "===== 1. OPERATOR SESSION (demo seed endpoint) =====" OPRESP=$(CURL -X POST "$BASE/api/auth/demo/operator-session" -H 'Content-Type: application/json' -d '{}') @@ -56,6 +70,61 @@ for path in "/api/trips?origin=probe&destination=probe" "/api/orders"; do echo " $path cold=${t1}s warm=${t2}s" done +echo "===== 5. S33 INTERNAL-ONLY PATHS (expect 404 — gateway blocks before routing) =====" +declare -A INTERNAL_ONLY_PATHS=( + ["POST /api/users"]="" + ["GET /api/users/probe-id"]="" + ["POST /api/orders/probe-id/pay"]="" + ["POST /api/orders/probe-id/timeout"]="" + ["POST /api/trips/probe-id/seat-locks"]="" + ["POST /api/payments/simulations"]="" + ["POST /api/payments/simulate-success"]="" +) +for spec in "${!INTERNAL_ONLY_PATHS[@]}"; do + method="${spec%% *}"; path="${spec#* }" + code=$(CURL -o /dev/null -w '%{http_code}' -X "$method" -H 'Content-Type: application/json' -d '{}' "$BASE$path") + if [ "$code" = "404" ]; then ok "$spec -> 404"; else bad "$spec -> $code (expected 404 — internal-only path externally reachable)"; fi +done + +echo "===== 6. DRIVER-REVIEW RBAC (S33; operator 200 / rider 403) =====" +RP="${RP:-138$(date +%s | tail -c 9)}" +R=$(login "$RP"); RTOK=${R%%|*} +[ -n "$RTOK" ] && ok "rider token minted for RBAC checks" || bad "rider login (resp: $R)" +code=$(CURL -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $OTOK" "$BASE/api/drivers/verification-cases") +[ "$code" = "200" ] && ok "GET verification-cases as operator -> 200" || bad "GET verification-cases as operator -> $code (expected 200)" +code=$(CURL -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $RTOK" "$BASE/api/drivers/verification-cases") +[ "$code" = "403" ] && ok "GET verification-cases as rider -> 403" || bad "GET verification-cases as rider -> $code (expected 403)" +code=$(CURL -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $RTOK" -H 'Content-Type: application/json' -d '{}' "$BASE/api/drivers/verification-cases/probe-id/approve") +[ "$code" = "403" ] && ok "POST approve as rider -> 403" || bad "POST approve as rider -> $code (expected 403)" + +echo "===== 7. KNOWN-GAP BASELINE (INFO only — docs/security.md deliberately-deferred items, does not fail the run) =====" +RP2="${RP2:-139$(date +%s | tail -c 9)}" # different prefix than RP so the two never collide +RB=$(login "$RP2"); RTOK_B=${RB%%|*} +if [ -n "$RTOK" ] && [ -n "$RTOK_B" ]; then + RID=${R#*|}; RID=${RID%%|*} + DEP=$(python3 -c "import datetime; print((datetime.datetime.now(datetime.UTC)+datetime.timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M:%SZ'))") + TRIP=$(CURL -X POST "$BASE/api/trips" -H "Authorization: Bearer $RTOK" -H 'Content-Type: application/json' \ + -d "{\"driverId\":\"$RID\",\"originText\":\"probe-origin\",\"destinationText\":\"probe-dest\",\"city\":\"probe\",\"departureAt\":\"$DEP\",\"totalSeats\":3}") + TID=$(echo "$TRIP" | j "['tripId']") + ORD=$(CURL -X POST "$BASE/api/orders" -H "Authorization: Bearer $RTOK" -H 'Content-Type: application/json' \ + -d "{\"tripId\":\"$TID\",\"seats\":1,\"idempotencyKey\":\"gapcheck-$(date +%s)\"}") + OID=$(echo "$ORD" | j "['orderId']") + if [ -n "$TID" ] && [ -n "$OID" ]; then + ordercode=$(CURL -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $RTOK_B" "$BASE/api/orders/$OID") + if [ "$ordercode" = "200" ]; then info "read-side IDOR still present: riderB got 200 on riderA's order $OID (documented, deferred)"; else info "GET /api/orders/{id} cross-user -> $ordercode (was documented as 200/deferred — behavior may have changed, worth checking)"; fi + tripcode=$(CURL -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $RTOK_B" "$BASE/api/trips/$TID") + if [ "$tripcode" = "200" ]; then info "read-side IDOR still present: riderB got 200 on riderA's trip $TID (documented, deferred)"; else info "GET /api/trips/{id} cross-user -> $tripcode (was documented as 200/deferred — behavior may have changed, worth checking)"; fi + SPOOF=$(CURL -X POST "$BASE/api/trips" -H "Authorization: Bearer $RTOK_B" -H 'Content-Type: application/json' \ + -d "{\"driverId\":\"$RID\",\"originText\":\"probe2\",\"destinationText\":\"probe2\",\"city\":\"probe\",\"departureAt\":\"$DEP\",\"totalSeats\":1}") + SPOOF_DRIVER=$(echo "$SPOOF" | j "['driverId']") + if [ "$SPOOF_DRIVER" = "$RID" ]; then info "trip publish still trusts body driverId (riderB's trip got driverId=riderA, documented, deferred)"; else info "trip publish driverId came back as '$SPOOF_DRIVER' (expected riderA's id if still deferred — behavior may have changed)"; fi + else + info "known-gap baseline skipped: could not create probe trip/order (trip=$TID order=$OID)" + fi +else + info "known-gap baseline skipped: could not mint both rider tokens" +fi + echo if [ "$FAILS" -eq 0 ]; then echo "ALL CHECKS PASSED" diff --git a/scripts/loadtest/booking-flow.js b/scripts/loadtest/booking-flow.js new file mode 100644 index 0000000..7067a83 --- /dev/null +++ b/scripts/loadtest/booking-flow.js @@ -0,0 +1,161 @@ +// Models scripts/demo-smoke.sh's full booking/payment/identity/review/cancel flow as a k6 load test. +// +// NEVER run this against the woxiangchuanaj.top production host — see guardNotProd() in lib/api.js, +// which refuses to run unless TARGET_BASE_URL is set, doesn't match a known-prod host, and +// I_UNDERSTAND_THIS_IS_NOT_FOR_PROD=yes is explicitly passed. This project's production host is a +// 3.4GiB/2-vCPU box already running at the edge of its memory budget (see docs/load-testing.md) — +// point this at a local Docker Compose stack, staging, or CI instead. +// +// Requires a demo-profile target (app.demo-mode=true): the SMS demo-inbox, operator-session, and +// demo-control endpoints this script depends on 404 outside that profile (docs/demo-mode.md). +// +// Usage: +// TARGET_BASE_URL=http://localhost:8120 I_UNDERSTAND_THIS_IS_NOT_FOR_PROD=yes \ +// k6 run -e VUS=5 -e DURATION=1m scripts/loadtest/booking-flow.js +// +// Env vars (all optional except TARGET_BASE_URL / I_UNDERSTAND_THIS_IS_NOT_FOR_PROD): +// VUS virtual users (default 5 — deliberately conservative starting point; this is a +// demo-scale system: Hikari pool=2/service, 96MB JVM heaps when run under the +// systemd lowmem profile — raise gradually against a target sized for it) +// DURATION run duration (default 1m) +// ACCOUNT_SEED salt mixed into trip origin/destination text, default 'lt' +// +// Thresholds below are generous starting points for an unknown target (laptop/CI/staging), not +// tuned to any specific host's capacity — tighten them once you know what "healthy" looks like +// for your target. + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; +import { login, operatorSession, authHeaders, randomPhone, nowPlusHoursIso, guardNotProd } from './lib/api.js'; + +const flowCompleted = new Rate('booking_flow_completed'); + +export const options = { + scenarios: { + booking_flow: { + executor: 'constant-vus', + vus: Number(__ENV.VUS || 5), + duration: __ENV.DURATION || '1m', + }, + }, + thresholds: { + http_req_failed: ['rate<0.05'], + http_req_duration: ['p(95)<2000'], + booking_flow_completed: ['rate>0.95'], + }, +}; + +export function setup() { + const base = guardNotProd(); + const operator = operatorSession(base); + check(operator.raw, { 'operator session minted': () => !!operator.token }); + if (!operator.token) { + throw new Error(`Could not mint operator session against ${base} — is this a demo-profile target? resp: ${operator.raw.body}`); + } + return { base, operatorToken: operator.token }; +} + +export default function (data) { + const { base, operatorToken } = data; + const seed = __ENV.ACCOUNT_SEED || 'lt'; + const operatorAuth = authHeaders(operatorToken); + + // 1. rider login (SMS via demo inbox) + const rider = login(base, randomPhone('138')); + const riderOk = check(rider.raw, { 'rider login ok': () => !!rider.token }); + if (!riderOk) { flowCompleted.add(false); return; } + const riderAuth = authHeaders(rider.token); + + // 2. publish trip (rider acts as driver, same pattern as demo-smoke.sh) + const dep = nowPlusHoursIso(1); + const tripRes = http.post(`${base}/api/trips`, JSON.stringify({ + driverId: rider.userId, + originText: `probe-origin-${seed}`, + destinationText: `probe-dest-${seed}`, + city: 'probe', + departureAt: dep, + totalSeats: 3, + }), riderAuth); + const trip = tripRes.json() || {}; + const tripOk = check(tripRes, { 'trip published': () => tripRes.status === 200 && !!trip.tripId }); + if (!tripOk) { flowCompleted.add(false); return; } + + // 3. search + const searchRes = http.get( + `${base}/api/trips?origin=${encodeURIComponent('probe-origin-' + seed)}&destination=${encodeURIComponent('probe-dest-' + seed)}`, + riderAuth + ); + check(searchRes, { 'search returned results': () => searchRes.status === 200 && Array.isArray(searchRes.json()) && searchRes.json().length >= 1 }); + + // 4. book seat + const orderRes = http.post(`${base}/api/orders`, JSON.stringify({ + tripId: trip.tripId, seats: 1, idempotencyKey: `lt-${rider.userId}-${Date.now()}`, + }), riderAuth); + const order = orderRes.json() || {}; + const orderOk = check(orderRes, { 'order PENDING_PAYMENT': () => order.status === 'PENDING_PAYMENT' }); + if (!orderOk) { flowCompleted.add(false); return; } + + // 5. create payment intent + const intentRes = http.post(`${base}/api/payments/intents`, JSON.stringify({ + orderId: order.orderId, idempotencyKey: `pi-${order.orderId}`, + }), riderAuth); + const intent = intentRes.json() || {}; + const intentOk = check(intentRes, { 'intent REQUIRES_PAYMENT': () => intent.status === 'REQUIRES_PAYMENT' }); + if (!intentOk) { flowCompleted.add(false); return; } + + // 6. operator drives signed payment success (goes through the real HMAC-signed callback + // pipeline in payment-sim-service, not a shortcut — see docs/architecture.md) + const cbRes = http.post(`${base}/api/demo/control/payment/${intent.intentId}/callbacks`, JSON.stringify({ + outcome: 'SUCCEEDED', mode: 'NORMAL', + }), operatorAuth); + check(cbRes, { 'payment callback SUCCEEDED': () => cbRes.status === 200 && cbRes.json('finalStatus') === 'SUCCEEDED' }); + + sleep(1); // let the internal markPaid Feign call land, same beat demo-smoke.sh gives it + + // 7. verify SEAT_LOCKED + const orderAfterPay = http.get(`${base}/api/orders/${order.orderId}`, riderAuth); + const seatLocked = check(orderAfterPay, { 'order SEAT_LOCKED': () => orderAfterPay.json('status') === 'SEAT_LOCKED' }); + + // 8. identity verification (rider starts, operator drives liveness then session) + const idRes = http.post(`${base}/api/identity/verifications`, JSON.stringify({ + realName: 'k6 probe', idNumber: '350211199001011234', + }), riderAuth); + const identity = idRes.json() || {}; + const idStarted = check(idRes, { 'identity session PENDING': () => identity.status === 'PENDING' }); + let identityApproved = false; + if (identity.verificationId) { + http.post(`${base}/api/demo/control/identity/${identity.verificationId}/liveness`, JSON.stringify({ outcome: 'PASSED' }), operatorAuth); + const sessRes = http.post(`${base}/api/demo/control/identity/${identity.verificationId}/session`, JSON.stringify({ outcome: 'APPROVED' }), operatorAuth); + identityApproved = check(sessRes, { 'identity APPROVED': () => sessRes.json('status') === 'APPROVED' }); + } + + // 9. complete order (operator) + const completeRes = http.post(`${base}/api/orders/${order.orderId}/complete`, null, operatorAuth); + const completeOk = check(completeRes, { 'order COMPLETED': () => completeRes.json('status') === 'COMPLETED' }); + + // 10. review + duplicate-review rejection + const reviewRes = http.post(`${base}/api/orders/${order.orderId}/review`, JSON.stringify({ rating: 5, comment: 'k6 probe' }), riderAuth); + check(reviewRes, { 'review submitted': () => reviewRes.json('rating') === 5 }); + const dupRes = http.post(`${base}/api/orders/${order.orderId}/review`, JSON.stringify({ rating: 1, comment: 'dup' }), riderAuth); + check(dupRes, { 'duplicate review rejected 409': () => dupRes.status === 409 }); + + // 11. second order -> cancel path + const order2Res = http.post(`${base}/api/orders`, JSON.stringify({ + tripId: trip.tripId, seats: 1, idempotencyKey: `lt2-${rider.userId}-${Date.now()}`, + }), riderAuth); + const order2 = order2Res.json() || {}; + if (order2.orderId) { + const cancelRes = http.post(`${base}/api/orders/${order2.orderId}/cancel`, null, riderAuth); + check(cancelRes, { 'order2 USER_CANCELLED': () => cancelRes.json('status') === 'USER_CANCELLED' }); + } + + // 12. negative authz: rider cannot hit operator demo control + const negRes = http.post(`${base}/api/demo/control/payment/${intent.intentId}/callbacks`, JSON.stringify({ + outcome: 'FAILED', mode: 'NORMAL', + }), riderAuth); + check(negRes, { 'rider blocked from demo control 403': () => negRes.status === 403 }); + + flowCompleted.add(riderOk && tripOk && orderOk && intentOk && seatLocked && idStarted && identityApproved && completeOk); + sleep(1); +} diff --git a/scripts/loadtest/lib/api.js b/scripts/loadtest/lib/api.js new file mode 100644 index 0000000..4aa5ede --- /dev/null +++ b/scripts/loadtest/lib/api.js @@ -0,0 +1,75 @@ +// Shared helpers for k6 scripts against the o2o-Local-Carpooling gateway API. +// Mirrors scripts/demo-smoke.sh's login() shape and flow so both stay in sync by construction. +import http from 'k6/http'; + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +// Hostnames this must never run against, checked by substring match against TARGET_BASE_URL. +const KNOWN_PROD_HOSTS = ['woxiangchuanaj.top']; + +// Call from setup() in every script. Throws (aborting the run before any request is sent) unless +// TARGET_BASE_URL is set, doesn't match a known-production host, and the operator has explicitly +// confirmed the target isn't production. Belt and suspenders against an accidental prod run. +export function guardNotProd() { + const target = __ENV.TARGET_BASE_URL || ''; + const confirm = __ENV.I_UNDERSTAND_THIS_IS_NOT_FOR_PROD || ''; + if (!target) { + throw new Error('TARGET_BASE_URL is required, e.g. TARGET_BASE_URL=http://localhost:8120'); + } + if (KNOWN_PROD_HOSTS.some((h) => target.includes(h))) { + throw new Error( + `REFUSING: TARGET_BASE_URL ("${target}") matches a known production host. ` + + `Never run this against ${KNOWN_PROD_HOSTS.join(', ')}.` + ); + } + if (confirm !== 'yes') { + throw new Error('REFUSING: set I_UNDERSTAND_THIS_IS_NOT_FOR_PROD=yes to confirm this target is not production.'); + } + return target; +} + +// $phone -> { token, userId, roles, raw }. Same SMS-code-via-demo-inbox flow as demo-smoke.sh's +// login(): only works against a demo-profile target (DemoEndpoints 404s the demo-inbox route +// otherwise — see docs/demo-mode.md). +export function login(base, phone) { + http.post(`${base}/api/auth/sms-code`, JSON.stringify({ phone }), { headers: JSON_HEADERS }); + const inboxRes = http.get(`${base}/api/auth/sms-code/demo-inbox?phone=${phone}`); + const code = inboxRes.json('code'); + const loginRes = http.post(`${base}/api/auth/login`, JSON.stringify({ phone, code }), { headers: JSON_HEADERS }); + const body = loginRes.json() || {}; + return { + token: body.accessToken, + userId: body.user && body.user.userId, + roles: body.user && body.user.roles, + raw: loginRes, + }; +} + +// Demo-only operator+admin bootstrap (POST /api/auth/demo/operator-session) — 404s outside the +// demo profile, same double-gate as everything else under docs/demo-mode.md. +export function operatorSession(base) { + const res = http.post(`${base}/api/auth/demo/operator-session`, '{}', { headers: JSON_HEADERS }); + const body = res.json() || {}; + return { + token: body.accessToken, + userId: body.user && body.user.userId, + roles: body.user && body.user.roles, + raw: res, + }; +} + +export function authHeaders(token) { + return { headers: { ...JSON_HEADERS, Authorization: `Bearer ${token}` } }; +} + +// Unique-enough synthetic phone number per VU+iteration+call, independent of clock resolution +// (VU/ITER are prefixed ahead of the timestamp so same-millisecond collisions across VUs can't +// happen). Format is deliberately not a "real" phone shape — this system doesn't validate one +// (scripts/demo-smoke.sh and scripts/check-deployment.sh both already rely on that). +export function randomPhone(prefix = '138') { + return `${prefix}${__VU}${__ITER}${Date.now()}`; +} + +export function nowPlusHoursIso(hours) { + return new Date(Date.now() + hours * 3600 * 1000).toISOString().replace(/\.\d+Z$/, 'Z'); +} diff --git a/scripts/loadtest/rate-limit-boundary.js b/scripts/loadtest/rate-limit-boundary.js new file mode 100644 index 0000000..e9c38f0 --- /dev/null +++ b/scripts/loadtest/rate-limit-boundary.js @@ -0,0 +1,104 @@ +// Verifies the gateway's fixed-window rate limits are enforced exactly at their configured +// boundaries: 20 req/60s per client IP on /api/auth/**, 120 req/60s per authenticated user on +// other /api/** (backend/gateway-service/src/main/resources/application.yml). Single VU, single +// iteration, ~150 fast sequential requests total for the whole run — this is a boundary probe, NOT +// sustained concurrent load. +// +// The limiter (backend/common's FixedWindowRateLimiter) uses wall-clock-epoch-aligned fixed +// windows, not a sliding window or token bucket, so a burst that straddles two windows would give +// a nondeterministic pass/fail count. This script waits for a fresh window before each burst. +// +// NEVER run this against the woxiangchuanaj.top production host — see guardNotProd() in lib/api.js. +// Requires a demo-profile target for the rider login helper (see docs/demo-mode.md); the rate +// limiter itself is not demo-gated, but this script's login() call is. +// Portability note: the in-memory rate limiter is single-instance-consistent only +// (RATE_LIMIT_BACKEND=memory, the default). If your target runs multiple gateway replicas behind a +// load balancer, this script's boundary assumptions only hold if RATE_LIMIT_BACKEND=redis there too. +// +// Usage: +// TARGET_BASE_URL=http://localhost:8120 I_UNDERSTAND_THIS_IS_NOT_FOR_PROD=yes \ +// k6 run scripts/loadtest/rate-limit-boundary.js + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; +import { login, authHeaders, randomPhone, guardNotProd } from './lib/api.js'; + +const boundaryCorrect = new Rate('rate_limit_boundary_correct'); + +export const options = { + scenarios: { + rate_limit_boundary: { + executor: 'shared-iterations', + vus: 1, + iterations: 1, + }, + }, + thresholds: { + rate_limit_boundary_correct: ['rate>0.99'], + }, +}; + +export function setup() { + const base = guardNotProd(); + return { base }; +} + +// Sleeps past the current fixed window's boundary if we're within `marginSeconds` of a rollover, +// so a burst can never straddle two windows. +function alignToFreshWindow(windowSeconds, marginSeconds = 10) { + const nowSec = Date.now() / 1000; + const intoWindow = nowSec % windowSeconds; + const remaining = windowSeconds - intoWindow; + if (remaining < marginSeconds) { + sleep(remaining + 0.5); + } +} + +function authBoundaryCheck(base) { + alignToFreshWindow(60); + const statuses = []; + for (let i = 0; i < 25; i++) { + const phone = `${randomPhone('137')}${i}`; + const res = http.post(`${base}/api/auth/sms-code`, JSON.stringify({ phone }), { headers: { 'Content-Type': 'application/json' } }); + statuses.push(res.status); + } + const firstTwenty = statuses.slice(0, 20); + const rest = statuses.slice(20); + const ok = firstTwenty.every((s) => s !== 429) && rest.length > 0 && rest.every((s) => s === 429); + check(ok, { 'auth rate limit: first 20 allowed, 21st+ get 429': (v) => v === true }); + if (!ok) { + console.warn(`auth boundary statuses: ${JSON.stringify(statuses)}`); + } + boundaryCorrect.add(ok); +} + +function apiBoundaryCheck(base) { + const rider = login(base, randomPhone('136')); + if (!rider.token) { + check(false, { 'api rate limit: rider login succeeded (prerequisite)': (v) => v === true }); + boundaryCorrect.add(false); + return; + } + alignToFreshWindow(60); + const auth = authHeaders(rider.token); + const statuses = []; + for (let i = 0; i < 125; i++) { + const res = http.get(`${base}/api/orders`, auth); + statuses.push(res.status); + } + const first120 = statuses.slice(0, 120); + const rest = statuses.slice(120); + const ok = first120.every((s) => s !== 429) && rest.length > 0 && rest.every((s) => s === 429); + check(ok, { 'api rate limit: first 120 allowed, 121st+ get 429': (v) => v === true }); + if (!ok) { + console.warn(`api boundary statuses: ${JSON.stringify(statuses)}`); + } + boundaryCorrect.add(ok); +} + +export default function (data) { + const { base } = data; + authBoundaryCheck(base); + apiBoundaryCheck(base); +}