Skip to content

fix(security): server-side API key management and SSRF hardening#33

Open
SweetSophia wants to merge 12 commits intoMiniMax-AI:mainfrom
SweetSophia:fix/server-side-api-keys
Open

fix(security): server-side API key management and SSRF hardening#33
SweetSophia wants to merge 12 commits intoMiniMax-AI:mainfrom
SweetSophia:fix/server-side-api-keys

Conversation

@SweetSophia
Copy link
Copy Markdown

Summary

Move API key storage from client-side localStorage to server-side config with proxy-based enforcement and redaction. Harden the LLM proxy against SSRF attacks.

Changes

  • Server-side key storage — secrets never reach the browser
  • SSRF protection via strict host allowlist (HTTPS required for public providers)
  • Header scrubbing, fetch timeout (90s), legacy config migration
  • SettingsModal UX: placeholder keys, local-edit guards, save error feedback
  • 115 tests passing

Checklist

  • pnpm run lint passes
  • pnpm build passes
  • Self-reviewed the code changes

SweetSophia and others added 11 commits April 1, 2026 19:44
* refactor: extract ChatPanel into focused modules

Decompose the 1472-line ChatPanel god component into four focused files:

- toolDefinitions.ts (133 lines): tool defs, system prompt builder, config guard
- ChatSubComponents.tsx (178 lines): CharacterAvatar, StageIndicator, ActionsTaken
- SettingsModal.tsx (270 lines): LLM + image generation configuration UI
- useConversationEngine.ts (341 lines): conversation loop + tool dispatch hook

ChatPanel/index.tsx reduced from 1472 → 632 lines (-57%), now a thin shell
that wires state to UI. No behavior changes — pure structural refactor.

Benefits:
- Conversation engine is testable in isolation
- Settings modal can be reasoned about independently
- Each file has a single clear responsibility
- Future PRs can target specific modules without touching the whole

* fix: consolidate duplicate ModManager imports in useConversationEngine.ts

Agent-Logs-Url: https://github.com/SweetSophia/OpenRoom/sessions/f4ebc43a-24c2-4853-b135-f234fd25d12b

Co-authored-by: SweetSophia <44297511+SweetSophia@users.noreply.github.com>

* fix: stabilize runConversation identity to prevent listener churn

runConversation was recreated on every render, causing the onUserAction
subscription effect to unsubscribe/resubscribe on each render. App actions
emitted during the cleanup-rebind window could be silently dropped.

Since runConversation only reads from refs (no reactive state), wrapping
it in useCallback with an empty dependency array makes its identity stable
across renders, eliminating the churn.

* fix: address code review findings across ChatPanel modules

useConversationEngine:
- Wrap callbacks in ref to prevent stale closure in stabilized runConversation
- Break outer loop after respond_to_user to avoid extra model round-trip
- Add console.warn for unparseable tool call arguments
- Handle loadMemories rejection with fallback to empty array

ChatSubComponents:
- Reactivate existing inactive layer instead of skipping it
- Use ref for cleanup timeout to prevent stale timeout interference

index.tsx:
- Move setSessionPath call from render body to useEffect
- Reduce reload effect deps to sessionPath only, add cancellation guard

SettingsModal:
- Sync local state from parent props via useEffect

toolDefinitions:
- Replace hardcoded emotion examples with generic reference to character keys

* fix: address review feedback on types, timeout cleanup, and config guard

Agent-Logs-Url: https://github.com/SweetSophia/OpenRoom/sessions/6cd6bd9b-6a80-474a-b067-8f2ff9f8f32a

Co-authored-by: SweetSophia <44297511+SweetSophia@users.noreply.github.com>

* fix: address Copilot review — tool loop, save snapshot, layer cleanup

useConversationEngine:
- Remove inner break on respond_to_user so sibling tool calls
  (e.g. finish_target) still execute before outer loop stops

index.tsx:
- Guard loadMemories .then/.catch with cancelled flag
- Snapshot session/data at debounce schedule time instead of
  reading refs at fire-time, preventing cross-session data mixing
- Remove now-unused sessionPathRef2

ChatSubComponents:
- On layer reactivation, cancel pending cleanup timeout and
  explicitly deactivate all other layers to prevent dual-active state

* fix: Prettier import style, stale memory guard, remove redundant ref

useConversationEngine:
- Break React import to multiline per Prettier config
- Guard loadMemories handlers against session change during async gap

index.tsx:
- Remove unused sessionPathRef_forSet ref — setSessionPath in
  useEffect alone is sufficient for module-level sync

* fix: flush debounced save on cleanup, remove stale dep in handleResetSession

index.tsx:
- Flush pending saveChatHistory in cleanup instead of discarding,
  preventing data loss on rapid session switches
- Remove modCollection from handleResetSession dependency array
  (uses refs and functional state updates, no direct read needed)

* fix(ChatPanel): useLayoutEffect for setSessionPath; fix debounced save write amplification

Agent-Logs-Url: https://github.com/SweetSophia/OpenRoom/sessions/4ddaf906-91f2-4ac3-ac97-7ce3fd331545

Co-authored-by: SweetSophia <44297511+SweetSophia@users.noreply.github.com>

* refactor: extract PendingSaveSnapshot type from inline ref type

Agent-Logs-Url: https://github.com/SweetSophia/OpenRoom/sessions/4ddaf906-91f2-4ac3-ac97-7ce3fd331545

Co-authored-by: SweetSophia <44297511+SweetSophia@users.noreply.github.com>

* fix: ModManager value import, sessionPath out of save deps, redact sensitive args from warn log

Agent-Logs-Url: https://github.com/SweetSophia/OpenRoom/sessions/1954e0a2-8dec-4da9-85f4-92df5e2ddf00

Co-authored-by: SweetSophia <44297511+SweetSophia@users.noreply.github.com>

* fix: only break loop when respond_to_user is sole tool call

Previously the loop broke after respond_to_user even when other tools
ran in the same batch. If the model planned to emit follow-up tool
calls (e.g. finish_target) in a subsequent round-trip, those were
silently skipped. Now the loop only terminates when respond_to_user
was the only tool call — batched tool calls get their follow-up
round-trip as intended.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
…state

1. Error Boundaries: Wrap AppWindow and ChatPanel in ErrorBoundary
   components so crashes don't take down the entire Shell.

2. Path Traversal: Replace regex-based sanitization in session-data
   and session-reset endpoints with resolve()-based validation that
   ensures paths stay within SESSIONS_DIR.

3. SSRF/Header Injection: LLM proxy now uses an explicit allowlist
   for forwarded headers. x-custom- headers are validated to prevent
   auth injection. Target URLs are validated for protocol.

4. AbortController: Conversation loop accepts AbortSignal.
   handleSend and processActionQueue cancel previous conversations
   before starting new ones.

5. Stale State: handleSend reads chatHistory from chatHistoryRef
   instead of closure, fixing race condition.
API keys are no longer stored in or sent from the browser:
- LLM proxy now reads keys from ~/.openroom/config.json and injects
  them server-side based on detected provider
- Browser clients (llmClient, imageGenClient) no longer send
  Authorization or x-api-key headers
- localStorage is no longer used as a config cache
- Provider auto-detection from target URL with manual override via
  X-LLM-Provider header

This eliminates XSS-based API key theft as an attack vector.
- Replace warn-only SSRF check with strict provider host allowlist
- Only known API hosts allowed (openai, anthropic, deepseek, etc.)
- Local LLM hosts only with ALLOW_LOCAL_LLM=true env var
- Validate target host BEFORE injecting server-side API keys
- Add isAllowedTarget() with test coverage
…ffect clobbering

- index.tsx onSave: fall back to submitted values on reload failure,
  only close modal when reload succeeds
- SettingsModal: always include customHeaders (send empty string to
  clear), add hasLocalEditsRef to prevent useEffect from clobbering
  in-progress edits
- vite.config.ts: inferProvider wraps URL parse in try/catch for
  malformed input
- viteConfig.test.ts: consistent env restore pattern, add malformed
  URL test for inferProvider
- e2e/app.spec.ts: stub /api/llm-config for deterministic unconfigured
  state in settings test
- import useRef in SettingsModal and mark API key edits as local edits
- avoid storing update objects/raw keys in ChatPanel state on reload failure
- return structured save results from config persistence and llmClient
- return 500 for server-side /api/llm-config write failures
- fix E2E config stub to return valid empty JSON
- update tests for structured save results
- mark provider/model UI interactions as local edits in SettingsModal
- clear preserved API keys when provider/base URL changes without a new key
- require HTTPS for public provider proxy targets
- add upstream proxy timeout handling
- expand vite config helper coverage for null/URL/env edge cases
Covers empty string, missing key, and whitespace-only apiKey — all should
produce hasApiKey=false.
Copilot AI review requested due to automatic review settings April 3, 2026 21:21
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR moves LLM/image-gen API key handling out of the browser and into a server-side Vite middleware, and hardens the LLM proxy against SSRF by enforcing a strict host allowlist and scrubbing sensitive headers. It also refactors ChatPanel into smaller units (engine/tool defs/settings modal) and updates docs/formatting across multiple app guides.

Changes:

  • Store secrets only in ~/.openroom/config.json and return redacted config to the client (hasApiKey flags), with server-side key injection in the proxy.
  • Add SSRF protections (target URL parsing + host allowlist + HTTPS enforcement) and stricter header forwarding + upstream timeout.
  • Refactor ChatPanel into extracted modules and add tests for Vite proxy/config helpers.

Reviewed changes

Copilot reviewed 39 out of 39 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
e2e/app.spec.ts Updates E2E behavior when config is missing to open Settings instead of sending.
apps/webuiapps/vite.config.ts Adds server-side config persistence/redaction, SSRF allowlist checks, header scrubbing, proxy timeout, and path traversal sanitization.
apps/webuiapps/src/pages/Twitter/twitter_en/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/Twitter/twitter_cn/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/MusicApp/meta/meta_en/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/MusicApp/meta/meta_cn/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/Gomoku/meta/meta_en/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/Gomoku/meta/meta_cn/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/FreeCell/freecell_en/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/FreeCell/freecell_cn/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/EvidenceVault/evidencevault_en/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/EvidenceVault/evidencevault_cn/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/Email/email_en/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/Email/email_cn/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/Diary/diary_en/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/Diary/diary_cn/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/CyberNews/meta/meta_en/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/CyberNews/meta/meta_cn/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/Chess/chess_en/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/Chess/chess_cn/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/Album/album_en/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/pages/Album/album_cn/guide.md Formatting-only guide adjustments.
apps/webuiapps/src/lib/llmModels.ts Adds hasApiKey and LLMConfigUpdate to support redacted configs + partial updates.
apps/webuiapps/src/lib/llmClient.ts Removes localStorage caching; routes requests through proxy with scope headers and server-side key injection.
apps/webuiapps/src/lib/imageGenTools.ts Uses hasUsableImageGenConfig to handle redacted configs.
apps/webuiapps/src/lib/imageGenClient.ts Removes localStorage caching; adds hasApiKey + update types + proxy scope header for image generation.
apps/webuiapps/src/lib/configPersistence.ts Implements update-based saving with structured {ok,error} results and legacy migration handling.
apps/webuiapps/src/lib/tests/viteConfig.test.ts Adds unit tests for Vite config helper functions (redaction, allowlist, parsing).
apps/webuiapps/src/lib/tests/llmClient.test.ts Updates tests to reflect server-side key injection and save-result surfacing.
apps/webuiapps/src/lib/tests/imageGenClient.test.ts Updates tests for redacted config loading, usability checks, and proxy header behavior.
apps/webuiapps/src/lib/tests/configPersistence.test.ts Updates tests for redacted config behavior and save error propagation.
apps/webuiapps/src/components/Shell/index.tsx Wraps AppWindow + ChatPanel in an ErrorBoundary to isolate crashes.
apps/webuiapps/src/components/ErrorBoundary.tsx Adds ErrorBoundary component with retryable fallback UI.
apps/webuiapps/src/components/ErrorBoundary.module.scss Styles for the ErrorBoundary fallback UI.
apps/webuiapps/src/components/ChatPanel/useConversationEngine.ts Extracts the conversation/tool-call loop into a hook.
apps/webuiapps/src/components/ChatPanel/toolDefinitions.ts Extracts tool definitions and system prompt builder.
apps/webuiapps/src/components/ChatPanel/SettingsModal.tsx New settings UI supporting placeholder keys + partial updates + save feedback.
apps/webuiapps/src/components/ChatPanel/index.tsx Refactors ChatPanel to use extracted modules and server-side config model.
apps/webuiapps/src/components/ChatPanel/ChatSubComponents.tsx Extracts ChatPanel helper UI components and message rendering.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +151 to +155
try {
fs.mkdirSync(resolve(os.homedir(), '.openroom'), { recursive: true });
fs.writeFileSync(LLM_CONFIG_FILE, body, 'utf-8');
fs.writeFileSync(LLM_CONFIG_FILE, JSON.stringify(mergedConfig), 'utf-8');
const stat = fs.statSync(LLM_CONFIG_FILE);
cachedServerConfig = { mtimeMs: stat.mtimeMs, value: mergedConfig };
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Config file writes store API keys under ~/.openroom/config.json, but writeFileSync currently uses default permissions (subject to umask), which can leave secrets world-readable on multi-user systems. Consider creating ~/.openroom with restrictive perms (e.g., 0700) and writing config.json with mode 0600 (and/or chmod after write) to ensure keys are not exposed to other local users.

Copilot uses AI. Check for mistakes.
Comment on lines +594 to +600
// Inject API key from server-side config
injectServerApiKey(
headers,
body,
});
parsedTargetUrl,
req.headers['x-llm-provider'] as string,
req.headers['x-llm-config-scope'] as string,
);
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

req.headers['x-llm-provider'] / x-llm-config-scope are also string | string[] | undefined. If they arrive as arrays, inferProvider(hint?.trim()) will throw. Please normalize these headers to a single string (or undefined) before passing them into injectServerApiKey.

Copilot uses AI. Check for mistakes.
Comment on lines +498 to +510
function isBlockedPassthroughHeader(headerName: string): boolean {
return (
headerName.startsWith('x-llm-') ||
[
'authorization',
'cookie',
'host',
'connection',
'proxy-authorization',
'x-api-key',
'x-goog-api-key',
].includes(headerName)
);
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proxy still allows a browser to forward arbitrary upstream headers via x-custom-*, but isBlockedPassthroughHeader() does not block hop-by-hop / framing headers like content-length, transfer-encoding, content-encoding, or accept-encoding. Allowing these can lead to request smuggling/broken upstream requests. Recommend adding them (and other hop-by-hop headers per RFC 9110) to the block list.

Copilot uses AI. Check for mistakes.
Comment on lines +216 to 223
// Sanitize: resolve path and ensure it stays within SESSIONS_DIR
const safePath = sanitizeRelativePath(relPath, SESSIONS_DIR);
if (safePath === null) {
res.writeHead(403);
res.end(JSON.stringify({ error: 'Invalid path' }));
return;
}
const filePath = join(SESSIONS_DIR, safePath);
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sanitizeRelativePath() returns a path slice using the platform separator (e.g. backslashes on Windows). sessionDataPlugin then uses that safePath with string concatenation and later computes dir via filePath.lastIndexOf('/'), which will break on Windows paths. Suggest normalizing to POSIX-style safePath for API usage and using path.dirname(filePath) (instead of string ops) when creating parent directories.

Copilot uses AI. Check for mistakes.
while (iterations < maxIterations) {
if (signal?.aborted) break;
iterations++;
const response = await chat(currentMessages, tools, cfg);
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runConversation accepts an AbortSignal, but it is not passed down to chat() / fetch, so cancellation will only be observed between iterations and won't stop an in-flight LLM request. Consider threading the signal through chat() and into the underlying fetch calls so switching sessions / sending a new request actually aborts the network request.

Suggested change
const response = await chat(currentMessages, tools, cfg);
const requestConfig = (
signal ? { ...cfg, signal } : cfg
) as LLMConfig;
const response = await chat(currentMessages, tools, requestConfig);

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +49
<div className={styles.fallback} data-testid="error-boundary-fallback">
<div className={styles.icon}>⚠️</div>
<div className={styles.message}>
{this.props.name ? `${this.props.name} crashed` : 'Something went wrong'}
</div>
<div className={styles.detail}>{this.state.error?.message}</div>
<button className={styles.retryBtn} onClick={this.handleRetry}>
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Retry button should specify type="button" to avoid unintended form submission if this boundary is ever rendered inside a <form>. Also consider adding role="alert" / aria-live on the fallback container so assistive tech announces the error state change.

Suggested change
<div className={styles.fallback} data-testid="error-boundary-fallback">
<div className={styles.icon}>⚠️</div>
<div className={styles.message}>
{this.props.name ? `${this.props.name} crashed` : 'Something went wrong'}
</div>
<div className={styles.detail}>{this.state.error?.message}</div>
<button className={styles.retryBtn} onClick={this.handleRetry}>
<div
className={styles.fallback}
data-testid="error-boundary-fallback"
role="alert"
aria-live="assertive"
>
<div className={styles.icon}>⚠️</div>
<div className={styles.message}>
{this.props.name ? `${this.props.name} crashed` : 'Something went wrong'}
</div>
<div className={styles.detail}>{this.state.error?.message}</div>
<button type="button" className={styles.retryBtn} onClick={this.handleRetry}>

Copilot uses AI. Check for mistakes.
Comment on lines 553 to +558
const body = Buffer.concat(chunks).toString();
const parsedTargetUrl = parseProxyTargetUrl(targetUrl);
if (!parsedTargetUrl) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid target URL' }));
return;
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

targetUrl is ultimately sourced from req.headers['x-llm-target-url'], whose type is string | string[] | undefined. If it arrives as an array (duplicate headers), parseProxyTargetUrl(targetUrl) will throw because it expects a string. Please validate/coerce the header value to a single string before parsing, and return a 400 when it's missing or not a string.

Copilot uses AI. Check for mistakes.
Comment on lines +183 to +188
// Resolve to absolute and verify it stays within baseDir
const resolved = resolve(baseDir, safe);
// Normalize both paths for comparison (resolve handles .. and symlinks)
const normalizedBase = resolve(baseDir);
if (!resolved.startsWith(normalizedBase + sep) && resolved !== normalizedBase) {
return null;
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In sanitizeRelativePath the comment says resolve handles .. and symlinks, but path.resolve() does not resolve symlinks. If the goal is to harden against symlink-based escapes, consider fs.realpathSync/realpath checks (where feasible) and update the comment so it matches the actual behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +688 to +691
if (nextConfig !== null && imageGenReloadSucceeded) {
setShowSettings(false);
}

Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onSave always returns { ok: true } even when the post-save reload fails (e.g., nextConfig === null), which means SettingsModal will not display an error and the user gets no feedback about why the modal didn't close. Consider returning { ok: false, error: ... } (or setting a reload-specific error) when the config reload does not succeed so the modal can show actionable feedback.

Suggested change
if (nextConfig !== null && imageGenReloadSucceeded) {
setShowSettings(false);
}
if (nextConfig === null || !imageGenReloadSucceeded) {
const reloadFailures = [
...(nextConfig === null ? ['chat settings'] : []),
...(!imageGenReloadSucceeded ? ['image generation settings'] : []),
];
return {
ok: false,
error: `Settings were saved, but reloading ${reloadFailures.join(' and ')} failed. Please reopen settings and verify the saved values.`,
};
}
setShowSettings(false);

Copilot uses AI. Check for mistakes.
PR MiniMax-AI#33 and MiniMax-AI#34 review feedback:

Security:
- Config file now written with mode 0600, directory with mode 0700
- Block hop-by-hop headers (RFC 9110) in proxy passthrough
- Validate header values are strings, not arrays, before use
- Fix misleading comment about symlink resolution in sanitizeRelativePath
- Redact sensitive data from file tool call summaries in chat history

UX / Correctness:
- Thread AbortSignal through chat() to fetch() for real request cancellation
- SettingsModal trims all inputs on save to prevent whitespace issues
- onSave returns proper error feedback when config reload fails
- hasUsableLLMConfig normalizes config in-place (trimmed baseUrl/model)
- Whitespace-only image gen API key no longer treated as enabled

Accessibility:
- ErrorBoundary: add role=alert, aria-live=assertive, type=button

Race conditions:
- handleMediaReady ignores stale loads when user switches emotions quickly

Tests: 118 passing
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants