Issue: #7700
Severity: MEDIUM (P2) · Category: Information Disclosure
Date: 2026-04-13
GET /api/settings/serverConfig is unauthenticated (baseApi({ auth: false })) and exposes 10 fields to any caller with no token required.
| Field | Risk | Reason |
|---|---|---|
pdfExpressViewerKey |
MEDIUM | Real PDF.js Express license key — can be stolen and used on other sites |
appfileBucketName / fabfileBucketName |
MEDIUM | Private S3 bucket names — enables enumeration/probing for misconfigured public access |
wsCompletionUrl |
LOW | Lambda URL exposed — connection-level probing possible |
websocketUrl |
LOW | WS endpoint exposed; handlers still require JWT auth |
seedStageName |
LOW | Leaks internal SST stage naming (dev = staging environment) |
googleClientId |
LOW | Can be misused for phishing/OAuth abuse; no pre-login callers found |
apiUrl |
ZERO | Zero-risk; no pre-login caller needs it from this endpoint |
cdnUrl |
ZERO | Every caller already has NEXT_PUBLIC_CDN_URL fallback — redundant here |
Run this on staging to confirm the issue exists before touching any code. Copy the output and save it — you will post it as a PR comment.
# Set your target
BASE_URL="https://app.staging.bike4mind.com"
echo "=== HYDRA-008 BEFORE: Unauthenticated serverConfig exposure ==="
echo "Target: $BASE_URL"
echo ""
# Hit the endpoint with no token
curl -s "$BASE_URL/api/settings/serverConfig" | jq .
echo ""
echo "=== Sensitive field check ==="
RESPONSE=$(curl -s "$BASE_URL/api/settings/serverConfig")
for field in websocketUrl wsCompletionUrl appfileBucketName fabfileBucketName pdfExpressViewerKey seedStageName googleClientId; do
VALUE=$(echo "$RESPONSE" | jq -r ".$field // empty")
if [ -n "$VALUE" ]; then
echo "EXPOSED $field = $VALUE"
else
echo "NOT FOUND $field"
fi
doneExpected output (confirms vulnerability):
EXPOSED websocketUrl = wss://20rx8hpb43.execute-api.us-east-2.amazonaws.com/$default
EXPOSED wsCompletionUrl = https://3crpfujchbfqx2guvr7polkjk40mkjwe.lambda-url.us-east-2.on.aws/
EXPOSED appfileBucketName = dev-bike4mind-buckets-appfilesbucket
EXPOSED fabfileBucketName = dev-bike4mind-buckets-fabfilesbucket
EXPOSED pdfExpressViewerKey = WantC7bbNZTCwnCdV7Lq
EXPOSED seedStageName = dev
EXPOSED googleClientId = 997737551293-...apps.googleusercontent.com
⚠️ Important divergence from original handoff notes.The original plan said: keep
serverConfigas public (minimal), create newserverConfigFullas auth'd.Recommended approach (inverted): Keep the full config at the existing
/api/settings/serverConfigURL but flip it toauth: true. Create/api/settings/serverConfigPublic(auth: false) for the minimal pre-login fields.Why: The CLI calls
/api/settings/serverConfigdirectly and already sends a Bearer token — it works with zero changes. Creating a new URL for the full config would break CLI completion.
| Field | Endpoint | Auth Required |
|---|---|---|
defaultTheme |
serverConfigPublic |
No |
apiUrl |
serverConfigPublic |
No (zero-risk, keep for safety) |
googleClientId |
serverConfig |
Yes — no pre-login callers exist |
websocketUrl |
serverConfig |
Yes |
wsCompletionUrl |
serverConfig |
Yes |
appfileBucketName |
serverConfig |
Yes |
fabfileBucketName |
serverConfig |
Yes |
pdfExpressViewerKey |
serverConfig |
Yes |
seedStageName |
serverConfig |
Yes |
cdnUrl |
serverConfig |
Yes (or remove — every caller has NEXT_PUBLIC_CDN_URL fallback) |
File: apps/client/pages/api/settings/serverConfig.ts
- Change
baseApi({ auth: false })→baseApi({ auth: true }) - Update
ServerConfigtype to removeapiUrlanddefaultTheme(they move to the public endpoint) - Everything else stays as-is
New file: apps/client/pages/api/settings/serverConfigPublic.ts
baseApi({ auth: false })- Returns only
{ apiUrl, defaultTheme } - New type:
ServerConfigPublic
File: apps/client/app/hooks/data/settings.ts
- Add
usePublicConfig()that fetches/api/settings/serverConfigPublic - Query key:
'server-config-public' - Keep
useConfig()pointing at/api/settings/serverConfig(now auth'd, same URL)
File: apps/client/app/providers.tsx (line 33)
// BEFORE
const PERSISTABLE_QUERY_KEYS = new Set(['server-config', 'branding-settings', 'admin-settings']);
// AFTER
const PERSISTABLE_QUERY_KEYS = new Set(['server-config-public', 'branding-settings', 'admin-settings']);
⚠️ Security critical: The full auth'd config must NOT be inPERSISTABLE_QUERY_KEYS. Bucket names and the PDF Express key must not be written to IndexedDB — they'd persist for up to 4 hours after logout.
File: apps/client/pages/login.tsx (~line 22)
// BEFORE
predicate: query => query.queryKey[0] !== 'server-config',
// AFTER
predicate: query => query.queryKey[0] !== 'server-config-public',
⚠️ Without this fix, bucket names and the PDF key survive logout in the React Query in-memory cache.
Move WebsocketConfigProvider inside UserProvider (since it now calls an auth'd endpoint).
// BEFORE
<WebsocketConfigProvider>
<WebsocketReactQueryInvalidateListener />
<UserProvider>
<ServerStatusProvider>{children}</ServerStatusProvider>
</UserProvider>
</WebsocketConfigProvider>
// AFTER
<UserProvider>
<WebsocketConfigProvider>
<WebsocketReactQueryInvalidateListener /> {/* stays inside WebsocketConfigProvider */}
<ServerStatusProvider>{children}</ServerStatusProvider>
</WebsocketConfigProvider>
</UserProvider>Verify before implementing:
StripeCheckoutSuccessHandlercurrently sits insideWebsocketConfigProviderbut outsideUserProvider. Check whether it callsuseUser()— if so, it must also move insideUserProvider.
File: apps/client/app/contexts/WebsocketConfigProvider.tsx
It calls useConfig() which now hits the auth'd serverConfig. The WebSocket already waits for accessToken before connecting — websocketUrl being undefined pre-login is already handled gracefully.
All 10 callers are post-login components and work unchanged since useConfig() still points to the same URL:
AdminLogoUpload.tsx,AdminModalTabNew.tsx,OrganizationProfile.tsx,SubscriptionModal.tsx—cdnUrlAttachFileButton.tsx,PromptReplies.tsx,GoogleDoc.tsx— bucket namess3.ts/useGetAppFileUrl(),useGetLogo.ts—cdnUrlPdfViewer.tsx—pdfExpressViewerKey
Run this against the PR preview environment. Replace BASE_URL and TOKEN with real values. 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-008 AFTER: Verifying serverConfig auth split ==="
echo "Target: $BASE_URL"
echo ""
# --- Check 1: serverConfig without token must return 401 ---
echo "--- [1] GET /api/settings/serverConfig (no token) ---"
STATUS=$(curl -s -o /tmp/sc_noauth.json -w "%{http_code}" "$BASE_URL/api/settings/serverConfig")
echo "HTTP Status: $STATUS"
if [ "$STATUS" = "401" ]; then
echo "PASS — 401 returned, sensitive fields no longer accessible without auth"
else
echo "FAIL — Expected 401, got $STATUS"
cat /tmp/sc_noauth.json | jq .
fi
echo ""
# --- Check 2: serverConfig WITH token must return 200 and full config ---
echo "--- [2] GET /api/settings/serverConfig (with token) ---"
STATUS=$(curl -s -o /tmp/sc_auth.json -w "%{http_code}" \
-H "Authorization: Bearer $TOKEN" \
"$BASE_URL/api/settings/serverConfig")
echo "HTTP Status: $STATUS"
if [ "$STATUS" = "200" ]; then
echo "PASS — Full config accessible with valid token"
echo "Fields returned: $(cat /tmp/sc_auth.json | jq -r 'keys | join(", ")')"
else
echo "FAIL — Expected 200, got $STATUS"
cat /tmp/sc_auth.json | jq .
fi
echo ""
# --- Check 3: serverConfigPublic without token must return 200 with only safe fields ---
echo "--- [3] GET /api/settings/serverConfigPublic (no token) ---"
STATUS=$(curl -s -o /tmp/sc_pub.json -w "%{http_code}" "$BASE_URL/api/settings/serverConfigPublic")
echo "HTTP Status: $STATUS"
echo "Fields returned: $(cat /tmp/sc_pub.json | jq -r 'keys | join(", ")')"
echo ""
echo "Safe fields (should be present):"
for field in apiUrl defaultTheme; do
VALUE=$(cat /tmp/sc_pub.json | jq -r ".$field // empty")
[ -n "$VALUE" ] && echo " PRESENT $field = $VALUE" || echo " MISSING $field"
done
echo ""
echo "Sensitive fields (must NOT be present):"
for field in websocketUrl wsCompletionUrl appfileBucketName fabfileBucketName pdfExpressViewerKey seedStageName googleClientId; do
VALUE=$(cat /tmp/sc_pub.json | jq -r ".$field // empty")
[ -n "$VALUE" ] && echo " FAIL — still exposed: $field = $VALUE" || echo " PASS — not exposed: $field"
doneExpected output (confirms fix):
--- [1] GET /api/settings/serverConfig (no token) ---
HTTP Status: 401
PASS — 401 returned, sensitive fields no longer accessible without auth
--- [2] GET /api/settings/serverConfig (with token) ---
HTTP Status: 200
PASS — Full config accessible with valid token
Fields returned: apiUrl, appfileBucketName, cdnUrl, defaultTheme, fabfileBucketName, googleClientId, pdfExpressViewerKey, seedStageName, websocketUrl, wsCompletionUrl
--- [3] GET /api/settings/serverConfigPublic (no token) ---
HTTP Status: 200
Fields returned: apiUrl, defaultTheme
Safe fields (should be present):
PRESENT apiUrl = https://app.prN.preview.bike4mind.com
PRESENT defaultTheme = groktool
Sensitive fields (must NOT be present):
PASS — not exposed: websocketUrl
PASS — not exposed: wsCompletionUrl
PASS — not exposed: appfileBucketName
PASS — not exposed: fabfileBucketName
PASS — not exposed: pdfExpressViewerKey
PASS — not exposed: seedStageName
PASS — not exposed: googleClientId
Post both BEFORE (Step 0) and AFTER (Step 9) outputs as a PR comment before requesting review.
The CLI already sends Bearer tokens on all requests (ApiClient.ts interceptor). Since serverConfig stays at the same URL (just now requires auth), the CLI works with zero changes.
File: e2e/warmup.setup.ts (~line 50)
Currently calls GET /api/settings/serverConfig without auth. After the fix it returns 401. Update to call GET /api/settings/serverConfigPublic (no auth needed) or add a valid token.
ServerSidePropsProvider.tsx — useServerSideProps() has zero callers. Safe to remove in a separate cleanup PR.
| File | Change |
|---|---|
pages/api/settings/serverConfig.ts |
Flip to auth: true; remove apiUrl + defaultTheme from type |
pages/api/settings/serverConfigPublic.ts |
New — auth: false, returns { apiUrl, defaultTheme } |
app/hooks/data/settings.ts |
Add usePublicConfig() pointing at serverConfigPublic |
app/providers.tsx |
Swap 'server-config' for 'server-config-public' in PERSISTABLE_QUERY_KEYS; move WebsocketConfigProvider inside UserProvider |
pages/login.tsx |
Preserve 'server-config-public' on logout, purge 'server-config' |
e2e/warmup.setup.ts |
Switch to serverConfigPublic |
No CLI changes required.
- PR description:
Closes #7700, closes #7704 - Post BEFORE output (Step 0) and AFTER output (Step 9) as a PR comment before requesting review
Note: This PR also closes #7704 as a side effect —
websocketUrlmoving behind auth andWebsocketConfigProviderrestructure in Steps 1 and 6 resolve it. See the HYDRA-013 gist for full #7704 context.
| Amendment | Priority | Impact |
|---|---|---|
| A — Invert naming (keep full config at existing URL, new public URL) | Critical | Eliminates all CLI breakage |
B — Remove full config from PERSISTABLE_QUERY_KEYS |
Critical | Prevents bucket names + PDF key persisting in IndexedDB post-logout |
| C — Fix logout handler to purge full config | High | Prevents sensitive config surviving logout in memory |
D — Move googleClientId behind auth |
Low | No pre-login callers; reduces unnecessary exposure |