黄云龙 97ca7f88cf
fix(frontend): harden artifact and markdown rendering (#4117)
* fix(frontend): harden artifact and markdown rendering

* fix(#4117): restore allow-scripts for scroll-restore, fix citation XSS bypass

willem-bd identified these issues:

1. [REGRESSION] sandbox removed allow-scripts which broke HTML artifact
   scroll-restoration — the injected postMessage script could not run.
   The prior config (allow-scripts allow-forms without allow-same-origin,
   i.e. opaque origin) was already safe. Restored with corrected comment.

2. [XSS bypass] CitationLink rendered <a href={href}> directly before
   isSafeHref ran, so prompt-injected [citation:x](javascript:...)
   bypassed the safety check. Moved citation block after the guard.

Also:
- Added mailto: and tel: to SAFE_HREF_PROTOCOLS
- Kept anchor-only attributes off the <span> fallback (React DOM warning)

Note: the loadMessages re-throw originally in this commit was dropped
during the rebase — upstream #4065 rewrote useThreadHistory as a
TanStack useInfiniteQuery that already surfaces fetch failures.

* test(e2e): update sandbox assertion to match allow-scripts allow-forms

The artifact preview iframe's sandbox was restored to 'allow-scripts allow-forms'
for scroll-restoration. Update the E2E test to expect the corrected value.

* fix(#4117): address review — ArtifactLink XSS guard, urlOfArtifact sandbox e2e

- Apply the isSafeHref guard to ArtifactLink (artifact-link.tsx) so a
  prompt-injected javascript:/data: href in a .md artifact preview cannot
  reach a real anchor in the main document, matching the guard in
  createMarkdownLinkComponent.
- Add an e2e asserting the urlOfArtifact iframe (non-code
  browser-previewable files like PDF) keeps its empty sandbox.

Note: the loadMessages error-surfacing changes originally in this commit
were dropped during the rebase — upstream #4065 rewrote useThreadHistory
as a TanStack useInfiniteQuery whose queryFn already throws on
!response.ok and surfaces the failure via a toast.

* fix(frontend): let write_file non-code previewable artifacts render in sandboxed iframe

When a write_file artifact such as PDF is clicked in chat, the component receives a path that forced isCodeFile=true, hiding the sandboxed iframe behind the code editor. Now non-code browser-previewable files are detected early so the sandboxed iframe renders correctly.

Fixes the E2E test: renders sandboxed iframe for a browser-previewable non-code file.

* fix(#4117): align PDF artifact route pattern with convention (drop /mock/ prefix)

The test used /mock/api/threads/... for the PDF artifact content route,
but the urlOfArtifact helper generates /api/threads/... (without /mock/)
when isMock=false. The other tests (e.g. presented artifacts) already use
the correct /api/threads/... pattern.

* test(frontend): add render-level coverage for unsafe markdown/artifact links

- Render MarkdownLink and ArtifactLink via renderToStaticMarkup and
  assert an unsafe javascript: href produces a disabled <span> (never an
  <a>), including through the citation-labelled branch, and that safe
  https hrefs render hardened anchors (target=_blank, rel=noopener).
- The new ArtifactLink render test caught the unsafe href leaking onto
  the fallback <span> through the {...rest} spread; drop the spread in
  both span fallbacks so anchor-only attributes (href/target/rel) and
  react-markdown's node prop never reach the span.
- Cover mailto:/tel: in the isSafeHref unit cases and fix the stale
  SAFE_HREF_PROTOCOLS docstring that still claimed http/https-only.

* fix(frontend): route the agent save-hint through the safe localStorage facade

Export safeLocalStorage from core/settings/local and use it for the
agent-create save-hint read/write so blocked browser storage (Safari
private mode, strict containers, embedded WebViews) cannot throw from
the effect. core/agents/feature-cache.ts already guards its
localStorage access with try/catch, so settings + agent pages are now
consistently best-effort.

* fix(frontend): allow scheme-less relative markdown links in isSafeHref
2026-07-21 11:35:44 +08:00

170 lines
4.0 KiB
TypeScript

import type { TokenUsageInlineMode } from "../messages/usage-model";
import type { AgentThreadContext } from "../threads";
export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
notification: {
enabled: true,
},
tokenUsage: {
headerTotal: true,
inlineMode: "per_turn",
},
context: {
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
},
};
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
function isBrowser(): boolean {
return typeof window !== "undefined";
}
/**
* Best-effort localStorage facade.
*
* Safari private mode, Firefox strict containers, some embedded WebViews, and
* quotas already filled by sibling tabs throw ``SecurityError`` or
* ``QuotaExceededError`` from ``getItem``/``setItem``. Without a guard those
* exceptions bubble into React render handlers and break the composer /
* settings panel. This wrapper traps every storage exception so callers can
* always fall back to a sane default.
*/
export const safeLocalStorage = {
getItem(key: string): string | null {
if (!isBrowser()) return null;
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
},
setItem(key: string, value: string): boolean {
if (!isBrowser()) return false;
try {
window.localStorage.setItem(key, value);
return true;
} catch {
return false;
}
},
removeItem(key: string): boolean {
if (!isBrowser()) return false;
try {
window.localStorage.removeItem(key);
return true;
} catch {
return false;
}
},
};
export interface LocalSettings {
notification: {
enabled: boolean;
};
tokenUsage: {
headerTotal: boolean;
inlineMode: TokenUsageInlineMode;
};
context: Omit<
AgentThreadContext,
| "thread_id"
| "is_plan_mode"
| "thinking_enabled"
| "subagent_enabled"
| "model_name"
| "reasoning_effort"
> & {
model_name?: string | undefined;
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
};
}
function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
return {
...DEFAULT_LOCAL_SETTINGS,
context: {
...DEFAULT_LOCAL_SETTINGS.context,
...settings?.context,
},
tokenUsage: {
...DEFAULT_LOCAL_SETTINGS.tokenUsage,
...settings?.tokenUsage,
},
notification: {
...DEFAULT_LOCAL_SETTINGS.notification,
...settings?.notification,
},
};
}
function getThreadModelStorageKey(threadId: string): string {
return `${THREAD_MODEL_KEY_PREFIX}${threadId}`;
}
export function getThreadModelName(threadId: string): string | undefined {
if (!isBrowser()) {
return undefined;
}
return (
safeLocalStorage.getItem(getThreadModelStorageKey(threadId)) ?? undefined
);
}
export function saveThreadModelName(
threadId: string,
modelName: string | undefined,
) {
if (!isBrowser()) {
return;
}
const key = getThreadModelStorageKey(threadId);
if (!modelName) {
safeLocalStorage.removeItem(key);
return;
}
safeLocalStorage.setItem(key, modelName);
}
export function applyThreadModelOverride(
settings: LocalSettings,
threadModelName: string | undefined,
): LocalSettings {
if (!threadModelName) {
return settings;
}
return {
...settings,
context: {
...settings.context,
model_name: threadModelName,
},
};
}
export function getLocalSettings(): LocalSettings {
if (!isBrowser()) {
return DEFAULT_LOCAL_SETTINGS;
}
const json = safeLocalStorage.getItem(LOCAL_SETTINGS_KEY);
try {
if (json) {
const settings = JSON.parse(json) as Partial<LocalSettings>;
return mergeLocalSettings(settings);
}
} catch {}
return DEFAULT_LOCAL_SETTINGS;
}
export function saveLocalSettings(settings: LocalSettings) {
if (!isBrowser()) {
return;
}
safeLocalStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
}