Skip to content

Instantly share code, notes, and snippets.

@allan-gar2x
Last active April 13, 2026 05:14
Show Gist options
  • Select an option

  • Save allan-gar2x/4ad57b8e11c4c88e6a27c840f8515b97 to your computer and use it in GitHub Desktop.

Select an option

Save allan-gar2x/4ad57b8e11c4c88e6a27c840f8515b97 to your computer and use it in GitHub Desktop.
HYDRA-008: serverConfig auth split — combined implementation + security review

HYDRA-008 — serverConfig Auth Split

Issue: #7700
Severity: MEDIUM (P2) · Category: Information Disclosure
Date: 2026-04-13


Vulnerability Summary

GET /api/settings/serverConfig is unauthenticated (baseApi({ auth: false })) and exposes 10 fields to any caller with no token required.


Risk Assessment

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

Step 0 — Verify the Vulnerability BEFORE Starting

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
done

Expected 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

The Fix: Two-Endpoint Split

Endpoint Naming (Security-Reviewed Decision)

⚠️ Important divergence from original handoff notes.

The original plan said: keep serverConfig as public (minimal), create new serverConfigFull as auth'd.

Recommended approach (inverted): Keep the full config at the existing /api/settings/serverConfig URL but flip it to auth: true. Create /api/settings/serverConfigPublic (auth: false) for the minimal pre-login fields.

Why: The CLI calls /api/settings/serverConfig directly and already sends a Bearer token — it works with zero changes. Creating a new URL for the full config would break CLI completion.

Final Field Split

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)

Implementation Steps

Step 1 — Backend: Flip serverConfig.ts to auth required

File: apps/client/pages/api/settings/serverConfig.ts

  • Change baseApi({ auth: false })baseApi({ auth: true })
  • Update ServerConfig type to remove apiUrl and defaultTheme (they move to the public endpoint)
  • Everything else stays as-is

Step 2 — Backend: Create serverConfigPublic.ts

New file: apps/client/pages/api/settings/serverConfigPublic.ts

  • baseApi({ auth: false })
  • Returns only { apiUrl, defaultTheme }
  • New type: ServerConfigPublic

Step 3 — Frontend: Add usePublicConfig() hook

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)

Step 4 — Frontend: Update PERSISTABLE_QUERY_KEYS

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 in PERSISTABLE_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.

Step 5 — Frontend: Fix the logout handler

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.

Step 6 — Frontend: Restructure providers.tsx

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: StripeCheckoutSuccessHandler currently sits inside WebsocketConfigProvider but outside UserProvider. Check whether it calls useUser() — if so, it must also move inside UserProvider.

Step 7 — WebsocketConfigProvider: No code changes needed

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.

Step 8 — All other useConfig() callers: No changes needed

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.tsxcdnUrl
  • AttachFileButton.tsx, PromptReplies.tsx, GoogleDoc.tsx — bucket names
  • s3.ts / useGetAppFileUrl(), useGetLogo.tscdnUrl
  • PdfViewer.tsxpdfExpressViewerKey

Step 9 — Confirm the Fix AFTER Deploying to PR Preview

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"
done

Expected 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.


CLI Impact

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.


e2e Test: Update Warmup

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.


Dead Code Opportunity

ServerSidePropsProvider.tsxuseServerSideProps() has zero callers. Safe to remove in a separate cleanup PR.


Files to Touch

File Change
pages/api/settings/serverConfig.ts Flip to auth: true; remove apiUrl + defaultTheme from type
pages/api/settings/serverConfigPublic.ts Newauth: 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 Requirements

  • 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 — websocketUrl moving behind auth and WebsocketConfigProvider restructure in Steps 1 and 6 resolve it. See the HYDRA-013 gist for full #7704 context.


Security Amendments (vs Original Handoff Notes)

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment