mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-13 08:18:57 +00:00
* 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
170 lines
4.0 KiB
TypeScript
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));
|
|
}
|