e5e2582743
收紧 PUBLIC 路径误匹配、强制 health/detail 与壁纸 sync 鉴权、默认关闭 OpenAPI; 安装锁定后 /api/install/* 统一 404;附探测脚本、测试与审计文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
184 lines
7.0 KiB
Bash
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"
|