Issue: #7704
Severity: LOW (P3) · Category: Information Disclosure
Date: 2026-04-13
Related PR (merged): #7708 — acute risks mitigated
Closed by: #7700 (WebSocket URL portion)
GET /api/settings/serverConfig (unauthenticated) returns websocketUrl to any caller with no token:
{ "websocketUrl": "wss://20rx8hpb43.execute-api.us-east-2.amazonaws.com/$default" }Risk: LOW — WebSocket handlers require JWT auth for data operations. Blast radius is connection-level probing only.
Resolution: Fully resolved as a side effect of the #7700 serverConfig auth split. No independent work required. See HYDRA-008 gist for implementation steps.
Steps from #7700 that close this:
- Step 1 —
serverConfig.tsflipped toauth: true→websocketUrlno longer accessible without a token - Step 6 —
WebsocketConfigProvidermoved insideUserProvider→ URL fetch only happens post-auth - Step 7 — No code changes needed in
WebsocketConfigProvider.tsx;undefinedURL already handled gracefully
Run on staging to confirm the vulnerability before touching any code. Save the output for the PR comment.
BASE_URL="https://app.staging.bike4mind.com"
echo "=== HYDRA-013 BEFORE: websocketUrl unauthenticated exposure ==="
echo "Target: $BASE_URL"
echo ""
STATUS=$(curl -s -o /tmp/sc_7704_before.json -w "%{http_code}" "$BASE_URL/api/settings/serverConfig")
echo "HTTP Status: $STATUS"
echo ""
WS_URL=$(cat /tmp/sc_7704_before.json | jq -r '.websocketUrl // empty')
if [ -n "$WS_URL" ]; then
echo "EXPOSED websocketUrl = $WS_URL"
echo ""
echo "Vulnerability confirmed — websocketUrl returned without authentication."
else
echo "NOT FOUND websocketUrl — may already be patched."
fiExpected output (confirms vulnerability):
HTTP Status: 200
EXPOSED websocketUrl = wss://20rx8hpb43.execute-api.us-east-2.amazonaws.com/$default
Run against the PR preview environment. TOKEN is a valid JWT — grab it from browser DevTools → Application → Local Storage → accessToken.
BASE_URL="https://app.prN.preview.bike4mind.com"
TOKEN="<paste_your_jwt_here>"
echo "=== HYDRA-013 AFTER: Verifying websocketUrl is behind auth ==="
echo "Target: $BASE_URL"
echo ""
# --- Check 1: No token — should NOT get websocketUrl ---
echo "--- [1] GET /api/settings/serverConfig (no token) ---"
STATUS=$(curl -s -o /tmp/sc_7704_noauth.json -w "%{http_code}" "$BASE_URL/api/settings/serverConfig")
echo "HTTP Status: $STATUS"
if [ "$STATUS" = "401" ]; then
echo "PASS — 401 returned, websocketUrl not accessible without token"
else
WS=$(cat /tmp/sc_7704_noauth.json | jq -r '.websocketUrl // empty')
[ -n "$WS" ] && echo "FAIL — websocketUrl still exposed: $WS" || echo "Unexpected status $STATUS but websocketUrl not present"
fi
echo ""
# --- Check 2: serverConfigPublic must NOT contain websocketUrl ---
echo "--- [2] GET /api/settings/serverConfigPublic (no token) ---"
curl -s -o /tmp/sc_7704_pub.json "$BASE_URL/api/settings/serverConfigPublic"
WS=$(cat /tmp/sc_7704_pub.json | jq -r '.websocketUrl // empty')
echo "Fields returned: $(cat /tmp/sc_7704_pub.json | jq -r 'keys | join(", ")')"
[ -n "$WS" ] && echo "FAIL — websocketUrl still in public endpoint: $WS" || echo "PASS — websocketUrl not in serverConfigPublic"
echo ""
# --- Check 3: With token — websocketUrl must still be accessible (WebSocket connect still works) ---
echo "--- [3] GET /api/settings/serverConfig (with token) ---"
STATUS=$(curl -s -o /tmp/sc_7704_auth.json -w "%{http_code}" \
-H "Authorization: Bearer $TOKEN" \
"$BASE_URL/api/settings/serverConfig")
echo "HTTP Status: $STATUS"
WS=$(cat /tmp/sc_7704_auth.json | jq -r '.websocketUrl // empty')
[ -n "$WS" ] && echo "PASS — websocketUrl accessible with valid token: $WS" || echo "FAIL — websocketUrl missing with token, WebSocket connect will break"Expected output (confirms fix):
--- [1] GET /api/settings/serverConfig (no token) ---
HTTP Status: 401
PASS — 401 returned, websocketUrl not accessible without token
--- [2] GET /api/settings/serverConfigPublic (no token) ---
Fields returned: apiUrl, defaultTheme
PASS — websocketUrl not in serverConfigPublic
--- [3] GET /api/settings/serverConfig (with token) ---
HTTP Status: 200
PASS — websocketUrl accessible with valid token: wss://20rx8hpb43.execute-api.us-east-2.amazonaws.com/$default
Post both BEFORE (Step 0) and AFTER (Step 9) outputs as a PR comment before requesting review.
PR description:Closes #7700, closes #7704
From erikbethke's comment on #7704 (2026-04-08):
"The remaining architectural question is whether emergency-login should exist at all, or be gated behind
isDevelopment(). Lowering priority since the acute risks are now mitigated."
File: apps/client/pages/api/admin/emergency-login.ts
Publicly accessible (baseApi({ auth: false })), but hardened by PR #7708:
const handler = baseApi({ auth: false })
.use(checkBlockedIP()) // HYDRA-005
.use(rateLimit({ limit: 5, windowMs: 10 * 60 * 1000 })) // HYDRA-006
.post(asyncHandler(async (req, res) => {
const { username, password } = EmergencyLoginSchema.parse(req.body); // HYDRA-001/002
const escapedUsername = escapeRegex(username); // HYDRA-001
// bcrypt compare + isAdmin + isBanned checks + audit log
}));| Finding | Fix |
|---|---|
| HYDRA-001 — Regex injection / ReDoS | escapeRegex() + ^...$ anchors |
| HYDRA-002 — NoSQL operator injection | EmergencyLoginSchema.parse() via Zod |
| HYDRA-003 — Username enumeration via logging | Minimal logging; uniform "Invalid credentials" for all failures |
| HYDRA-005 — Blocked IP bypass | checkBlockedIP() middleware added |
| HYDRA-006 — Zero rate limiting | rateLimit({ limit: 5, windowMs: 10 * 60 * 1000 }) |
BASE_URL="https://app.staging.bike4mind.com"
echo "=== Emergency-Login: Confirming current hardening on staging ==="
echo ""
# --- Check 1: Endpoint is reachable (returns 401 for bad credentials) ---
echo "--- [1] Endpoint reachability ---"
STATUS=$(curl -s -o /tmp/el_reach.json -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d '{"username":"nonexistent_hydra_test","password":"wrongpassword"}' \
"$BASE_URL/api/admin/emergency-login")
echo "HTTP Status: $STATUS"
[ "$STATUS" = "401" ] && echo "Reachable — returns 401 for bad credentials (expected)" || echo "Status: $STATUS — $(cat /tmp/el_reach.json | jq .)"
echo ""
# --- Check 2: Zod validation blocks NoSQL injection ---
echo "--- [2] NoSQL injection (expect 422) ---"
STATUS=$(curl -s -o /tmp/el_nosql.json -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d '{"username":{"$gt":""},"password":{"$gt":""}}' \
"$BASE_URL/api/admin/emergency-login")
echo "HTTP Status: $STATUS"
[ "$STATUS" = "422" ] && echo "PASS — Zod validation blocks NoSQL operators (422)" || echo "FAIL — Expected 422, got $STATUS"
echo ""
# --- Check 3: Rate limiting kicks in after 5 attempts ---
echo "--- [3] Rate limiting (expect 429 by attempt 6) ---"
for i in 1 2 3 4 5 6; do
STATUS=$(curl -s -o /tmp/el_rate_$i.json -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "{\"username\":\"ratelimit_probe_$i\",\"password\":\"wrongpassword\"}" \
"$BASE_URL/api/admin/emergency-login")
echo " Attempt $i: HTTP $STATUS"
if [ "$STATUS" = "429" ]; then
echo " PASS — Rate limit enforced at attempt $i"
break
fi
doneGating behind isDevelopment() defeats the purpose. emergency-login is a break-glass mechanism for when production OAuth is unavailable (Google OAuth misconfigured, provider outage). If it only works in development it is useless when you actually need it.
- Permanently discoverable.
/api/admin/emergency-loginis a predictable path. Route enumeration finds it and reveals a password-based admin auth path exists separate from OAuth. - Slow brute-force is still possible. 5 attempts / 10 min slows but does not stop a patient attacker with a known admin username and a credential dump.
- Parallel auth path outside the primary security model. Normal auth runs through Google OAuth — token rotation, session management, and Google's abuse detection all apply. Emergency login bypasses all of that.
Add an SST secret EMERGENCY_LOGIN_ENABLED that defaults to false. The endpoint returns 404 (not 403 — don't reveal it exists) unless explicitly enabled. Enable during an incident, use it, disable immediately.
Code change in emergency-login.ts (top of handler, before middleware):
// Gate: endpoint is closed by default. Enable via SST secret during incidents only.
if (process.env.EMERGENCY_LOGIN_ENABLED !== 'true') {
return res.status(404).end();
}SST commands to toggle:
# Enable on staging during an incident
npx sst secret set EMERGENCY_LOGIN_ENABLED "true" --stage dev
# Enable on production during an incident
npx sst secret set EMERGENCY_LOGIN_ENABLED "true" --stage production
# Disable immediately after use
npx sst secret set EMERGENCY_LOGIN_ENABLED "false" --stage production
npx sst secret set EMERGENCY_LOGIN_ENABLED "false" --stage dev
# Confirm current value
npx sst secret list --stage productionVerify the flag works after implementing:
BASE_URL="https://app.prN.preview.bike4mind.com"
echo "=== Emergency-Login: Confirming feature flag ==="
# With flag OFF (default) — should return 404
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d '{"username":"test","password":"test"}' \
"$BASE_URL/api/admin/emergency-login")
echo "Flag OFF — HTTP Status: $STATUS"
[ "$STATUS" = "404" ] && echo "PASS — endpoint hidden (404)" || echo "FAIL — Expected 404, got $STATUS"Expected output:
Flag OFF — HTTP Status: 404
PASS — endpoint hidden (404)
Restrict access to known admin IP ranges or a VPN CIDR block. Returns 404 (not 403) for unknown IPs — does not reveal the endpoint exists.
Code change in emergency-login.ts (add before existing middleware):
const ADMIN_IP_ALLOWLIST = (process.env.EMERGENCY_LOGIN_ALLOWED_IPS || '')
.split(',')
.map(ip => ip.trim())
.filter(Boolean);
function adminIPAllowlist() {
return (req: Request, res: Response, next: NextFunction) => {
if (ADMIN_IP_ALLOWLIST.length === 0) {
return res.status(404).end(); // No allowlist configured = closed by default
}
const clientIP = req.ip || req.connection.remoteAddress || '';
if (!ADMIN_IP_ALLOWLIST.includes(clientIP)) {
return res.status(404).end();
}
next();
};
}
const handler = baseApi({ auth: false })
.use(adminIPAllowlist()) // ← add this first
.use(checkBlockedIP())
.use(rateLimit({ limit: 5, windowMs: 10 * 60 * 1000 }))
// ...rest unchangedSST secret to configure allowed IPs:
# Set allowed IPs (comma-separated — use your VPN exit node or office IP)
npx sst secret set EMERGENCY_LOGIN_ALLOWED_IPS "203.0.113.10,203.0.113.11" --stage production
npx sst secret set EMERGENCY_LOGIN_ALLOWED_IPS "203.0.113.10,203.0.113.11" --stage dev
# Confirm
npx sst secret list --stage productionVerify IP allowlist works after implementing:
BASE_URL="https://app.prN.preview.bike4mind.com"
echo "=== Emergency-Login: Confirming IP allowlist ==="
# From an IP NOT in the allowlist — should return 404
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d '{"username":"test","password":"test"}' \
"$BASE_URL/api/admin/emergency-login")
echo "From non-allowlisted IP — HTTP Status: $STATUS"
[ "$STATUS" = "404" ] && echo "PASS — endpoint hidden from non-allowlisted IP" || echo "FAIL — Expected 404, got $STATUS"When to pick Option B over Option A: If the team has a static VPN exit node or a known office IP, Option B is strictly stronger — it eliminates discoverability entirely for the internet at large, even when the endpoint is "enabled." Option A is simpler and has no IP dependency.
| Approach | Discoverable | Production capable | Requires deploy to toggle |
|---|---|---|---|
isDevelopment() gate |
Hidden in prod | No — useless in prod | Yes |
| SST feature flag — Option A (recommended) | Hidden by default | Yes | No |
| IP allowlist — Option B (strongest) | Hidden from non-admin IPs | Yes | No (SST secret) |
| Keep as-is | Always discoverable | Yes | — |
| Sub-issue | Status | Action |
|---|---|---|
| WebSocket URL exposed unauthenticated | Closed by #7700 | Run verification scripts above, post in PR comment |
| Emergency-login acute risks (HYDRA-001/002/003/005/006) | Fixed in PR #7708 | None |
| Emergency-login architectural question | Open | Implement SST feature flag — track in a dedicated issue |
The emergency-login architectural question should be tracked in its own issue rather than as a comment on #7704 (a WebSocket URL issue).