Files
Nexus/scripts/security_probe_external.sh
T
Nexus Agent e5e2582743 fix(security): 外网攻击面加固 BL-01~05
收紧 PUBLIC 路径误匹配、强制 health/detail 与壁纸 sync 鉴权、默认关闭 OpenAPI;
安装锁定后 /api/install/* 统一 404;附探测脚本、测试与审计文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 01:45:10 +08:00

184 lines
7.0 KiB
Bash

#!/usr/bin/env bash
# security_probe_external.sh — Read-only external attack-surface probes (no credentials).
# Usage: NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
set -euo pipefail
BASE="${NEXUS_PROBE_BASE:-https://api.synaglobal.vip}"
REPORT_DIR="${NEXUS_PROBE_REPORT_DIR:-/tmp/nexus-external-probe}"
mkdir -p "$REPORT_DIR"
OUT="$REPORT_DIR/probe-$(date -u +%Y%m%dT%H%M%SZ).txt"
log() { echo "$@" | tee -a "$OUT"; }
plog() { echo "$@" | tee -a "$OUT" >&2; }
pass() { log " [PASS] $*"; }
fail() { log " [FAIL] $*"; }
info() { log " [INFO] $*"; }
warn() { log " [WARN] $*"; }
# probe METHOD PATH [JSON_BODY] [extra curl args...]
# Prints HTTP status code to stdout only.
probe() {
local method="$1" path="$2" body="${3:-}"
shift 3 || true
local url="${BASE}${path}"
local tmp
tmp="$(mktemp)"
local -a curl_args=(-sS -o "$tmp" -w "%{http_code}" --max-time 20)
if [[ "$method" == "GET" ]]; then
:
else
curl_args+=(-X "$method" -H "Content-Type: application/json")
[[ -n "$body" ]] && curl_args+=(-d "$body")
fi
# shellcheck disable=SC2206
local code
code=$(curl "${curl_args[@]}" "$@" "$url" 2>>"$OUT" || echo "000")
local snippet
snippet="$(head -c 400 "$tmp" | tr '\n' ' ')"
plog ""
plog "── $method $path → HTTP $code"
plog " body: ${snippet:-<empty>}"
rm -f "$tmp"
echo "$code"
}
log "═══ Nexus external attack-surface probe ═══"
log "BASE=$BASE"
log "OUT=$OUT"
log "time=$(date -u -Iseconds)"
# ── Health ──
c=$(probe GET /health)
[[ "$c" == "200" ]] && pass "GET /health → 200" || fail "GET /health code=$c"
c=$(probe GET /health/detail)
[[ "$c" == "401" ]] && pass "GET /health/detail requires JWT" || fail "GET /health/detail code=$c (expected 401) — component leak if 200"
# ── OpenAPI / docs ──
c=$(probe GET /openapi.json)
[[ "$c" == "200" ]] && warn "GET /openapi.json accessible (schema disclosure)" || info "GET /openapi.json code=$c"
c=$(probe GET /docs)
[[ "$c" == "200" ]] && warn "GET /docs accessible" || info "GET /docs code=$c"
c=$(probe GET /redoc)
[[ "$c" == "200" ]] && warn "GET /redoc accessible" || info "GET /redoc code=$c"
# ── Install API ──
c=$(probe GET /api/install/status)
if [[ "$c" == "404" ]]; then
pass "GET /api/install/status hidden when locked (404)"
elif [[ "$c" == "200" ]]; then
warn "GET /api/install/status returns 200 (install state leak)"
else
info "GET /api/install/status code=$c"
fi
for ep in env-check connection-check state; do
c=$(probe GET "/api/install/$ep")
[[ "$c" == "404" ]] && pass "GET /api/install/$ep hidden when locked (404)" || fail "GET /api/install/$ep code=$c (expected 404)"
done
c=$(probe POST /api/install/init-db '{"db_host":"x"}')
[[ "$c" == "404" ]] && pass "POST /api/install/init-db hidden when locked (404)" || fail "POST /api/install/init-db code=$c (expected 404)"
c=$(probe POST /api/install/create-admin '{"username":"x","password":"x","install_token":"x"}')
[[ "$c" == "404" ]] && pass "POST /api/install/create-admin hidden when locked (404)" || fail "POST /api/install/create-admin code=$c (expected 404)"
c=$(probe POST /api/install/test-credentials '{}')
[[ "$c" == "404" ]] && pass "POST /api/install/test-credentials hidden when locked (404)" || fail "POST /api/install/test-credentials code=$c (expected 404)"
c=$(probe POST /api/install/lock '{}')
[[ "$c" == "404" ]] && pass "POST /api/install/lock hidden when locked (404)" || fail "POST /api/install/lock code=$c (expected 404)"
# ── Public settings ──
c=$(probe GET /api/settings/bing-wallpapers)
[[ "$c" == "200" ]] && pass "GET /api/settings/bing-wallpapers public read-only" || fail "code=$c"
c=$(probe POST /api/settings/bing-wallpapers/sync '{}')
[[ "$c" == "401" ]] && pass "POST bing-wallpapers/sync requires JWT" || fail "POST bing-wallpapers/sync code=$c (expected 401)"
# ── Protected admin API ──
c=$(probe GET /api/servers/)
[[ "$c" == "401" ]] && pass "GET /api/servers/ requires JWT" || fail "code=$c"
c=$(probe POST /api/servers/ '{"name":"x","domain":"1.2.3.4"}')
[[ "$c" == "401" ]] && pass "POST /api/servers/ requires JWT" || fail "code=$c"
c=$(probe GET /api/audit/)
[[ "$c" == "401" ]] && pass "GET /api/audit/ requires JWT" || fail "code=$c"
# ── Agent ──
c=$(probe POST /api/agent/heartbeat '{"server_id":1,"is_online":true}')
[[ "$c" == "401" || "$c" == "422" ]] && pass "POST /api/agent/heartbeat no key rejected ($c)" || fail "code=$c"
c=$(probe POST /api/agent/heartbeat '{"server_id":1,"is_online":true}' -H "X-API-Key: invalid-probe-key-000")
[[ "$c" == "401" || "$c" == "422" ]] && pass "POST /api/agent/heartbeat bad key rejected ($c)" || fail "code=$c"
c=$(probe POST /api/agent/script-callback \
'{"job_id":"probe","server_id":1,"secret":"x","exit_code":0}' \
-H "X-API-Key: invalid-probe-key-000")
[[ "$c" == "401" || "$c" == "404" || "$c" == "429" ]] && pass "POST script-callback bad key ($c)" || fail "code=$c"
# ── Auth login (single probe) ──
login_tmp="$(mktemp)"
curl -sS -o "$login_tmp" --max-time 15 -X POST \
-H "Content-Type: application/json" \
-d '{"username":"nonexistent_probe_user","password":"wrong"}' \
"${BASE}/api/auth/login" 2>>"$OUT" || true
login_body="$(cat "$login_tmp")"
rm -f "$login_tmp"
info "login wrong user: $login_body"
[[ "$login_body" == *"用户名或密码错误"* ]] && pass "login message unified (no user enumeration)" || warn "login message may leak enumeration"
# ── Static agent ──
c=$(probe GET /agent/install.sh)
[[ "$c" == "200" ]] && info "GET /agent/install.sh public (expected)" || info "code=$c"
# ── WebSocket ──
if command -v python3 >/dev/null 2>&1; then
log ""
log "── WebSocket probes"
python3 - "$BASE" 2>>"$OUT" <<'PY' | tee -a "$OUT" || true
import asyncio, sys
try:
import websockets
except ImportError:
print(" [SKIP] websockets not installed")
sys.exit(0)
base = sys.argv[1].rstrip("/")
ws_base = base.replace("https://", "wss://").replace("http://", "ws://")
async def probe_ws(path, token=None):
url = f"{ws_base}{path}"
if token is not None:
url += f"?token={token}"
try:
async with websockets.connect(url, open_timeout=10, close_timeout=5) as ws:
print(f" [FAIL] {path} connected without expected close")
except Exception as e:
msg = str(e)
if "4001" in msg or "Missing JWT" in msg:
print(f" [PASS] {path} no token → rejected ({msg[:80]})")
elif "4401" in msg or "Invalid" in msg or "expired" in msg.lower():
print(f" [PASS] {path} bad token → rejected ({msg[:80]})")
elif "403" in msg:
print(f" [INFO] {path} → reverse proxy 403 (app 4001/4401 not observable)")
else:
print(f" [INFO] {path} → {msg[:120]}")
async def main():
await probe_ws("/ws/alerts")
await probe_ws("/ws/alerts", "invalid.jwt.token")
await probe_ws("/ws/sync")
await probe_ws("/ws/terminal/1", "invalid.jwt.token")
asyncio.run(main())
PY
fi
log ""
log "═══ Probe complete ═══"
log "Full log: $OUT"