feat(web-ai): render markdown answers safely

This commit is contained in:
2026-07-19 22:02:49 +08:00
parent 7bd2c540fa
commit c17d83fc59
5 changed files with 225 additions and 2 deletions
@@ -85,7 +85,7 @@ public interface SystemConstants {
/**
* 排除敏感属性字段
*/
String[] EXCLUDE_PROPERTIES = { "password", "oldPassword", "newPassword", "confirmPassword" };
String[] EXCLUDE_PROPERTIES = { "password", "oldPassword", "newPassword", "confirmPassword", "apiKey", "api_key" };
}
@@ -8,6 +8,7 @@ import org.dromara.aihr.webai.AihrWebSearchClient.SearchResult;
import org.dromara.aihr.webai.AihrWebSearchClient.WebSource;
import org.dromara.aihr.webai.AihrWebSearchProviderService.ProviderRequest;
import org.dromara.aihr.webai.AihrWebSearchProviderService.RuntimeProvider;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@@ -54,6 +55,13 @@ class AihrWebAiSecurityTest {
assertFalse(mapper.writeValueAsString(request).contains("must-not-reach-logs"));
}
@Test
@Tag("dev")
void requestLogMasksProviderApiKeys() {
assertTrue(List.of(SystemConstants.EXCLUDE_PROPERTIES).contains("apiKey"));
assertTrue(List.of(SystemConstants.EXCLUDE_PROPERTIES).contains("api_key"));
}
@Test
@Tag("dev")
void searchEndpointRejectsLocalAndPrivateNetworks() {
+72 -1
View File
@@ -62,7 +62,7 @@
<view class="bubble master">
<text v-if="msg.result.status === 'NO_ANSWER'" class="bubble-noevidence">已找到公开来源,但外部服务未返回可核验答案,请直接查看来源。</text>
<text v-else-if="msg.result.noEvidence" class="bubble-noevidence">未找到可核验的公开依据,本次不生成推断答案。</text>
<text v-else class="bubble-text">{{ msg.result.answer }}</text>
<rich-text v-else class="bubble-text bubble-markdown" :nodes="renderMarkdown(msg.result.answer)" />
<text v-if="msg.result.redacted" class="bubble-redacted">已自动隐藏问题中的个人或门禁信息</text>
<text v-if="msg.result.warning" class="bubble-warning">{{ msg.result.warning }}</text>
@@ -119,6 +119,7 @@ import ChatComposer from '@/components/chat/ChatComposer.vue';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { chooseSpeechAudio, createSpeechPlaybackController, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { getWebAiCapabilities, queryWebAi } from '@/services/web-ai';
import { renderMarkdown } from '@/utils/markdown';
const path = '/pages/user/web-ai/index';
@@ -505,6 +506,76 @@ onUnload(() => {
white-space: pre-wrap;
}
.bubble-markdown {
white-space: normal;
}
.bubble-markdown :deep(p),
.bubble-markdown :deep(h1),
.bubble-markdown :deep(h2),
.bubble-markdown :deep(h3),
.bubble-markdown :deep(blockquote),
.bubble-markdown :deep(ul),
.bubble-markdown :deep(ol),
.bubble-markdown :deep(pre) {
margin: 0 0 8px;
}
.bubble-markdown :deep(h1),
.bubble-markdown :deep(h2),
.bubble-markdown :deep(h3) {
color: #18202b;
font-weight: 800;
line-height: 1.4;
}
.bubble-markdown :deep(h1) { font-size: 18px; }
.bubble-markdown :deep(h2) { font-size: 17px; }
.bubble-markdown :deep(h3) { font-size: 16px; }
.bubble-markdown :deep(ul),
.bubble-markdown :deep(ol) {
padding-left: 20px;
}
.bubble-markdown :deep(li + li) {
margin-top: 4px;
}
.bubble-markdown :deep(blockquote) {
padding-left: 10px;
border-left: 3px solid #cdd8e4;
color: #5d6875;
}
.bubble-markdown :deep(a) {
color: #315b8f;
text-decoration: underline;
}
.bubble-markdown :deep(code) {
padding: 1px 4px;
border-radius: 4px;
background: #eef1f4;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em;
}
.bubble-markdown :deep(pre) {
max-width: 100%;
box-sizing: border-box;
padding: 8px;
overflow-x: auto;
border-radius: 6px;
background: #eef1f4;
white-space: pre;
}
.bubble-markdown :deep(pre code) {
padding: 0;
background: transparent;
}
.bubble-subnote {
display: block;
margin-top: 8px;
+120
View File
@@ -0,0 +1,120 @@
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char] || char));
const safeHref = (value: string) => {
try {
const url = new URL(value.trim());
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : '';
} catch {
return '';
}
};
const inline = (source: string): string => {
const tokens: string[] = [];
const token = (html: string) => `\uE000${tokens.push(html) - 1}\uE001`;
let value: string = source
.replace(/`([^`\n]+)`/g, (_, code: string) => token(`<code>${escapeHtml(code)}</code>`))
.replace(/\[([^\]\n]+)]\(([^)\n]+)\)/g, (_, label: string, rawHref: string) => {
const href = safeHref(rawHref);
return href ? token(`<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${inline(label)}</a>`) : _;
});
value = escapeHtml(value)
.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
.replace(/__([^_\n]+)__/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
.replace(/(^|[^_])_([^_\n]+)_/g, '$1<em>$2</em>');
return value.replace(/\uE000(\d+)\uE001/g, (_: string, index: string) => tokens[Number(index)] || '');
};
/** Converts the small Markdown subset returned by Web AI into safe rich-text HTML. */
export const renderMarkdown = (value: string): string => {
const lines = String(value || '').replace(/\r\n?/g, '\n').split('\n');
const output: string[] = [];
let paragraph: string[] = [];
let quote: string[] = [];
let listType: 'ol' | 'ul' | null = null;
let listItems: string[] = [];
let code: string[] | null = null;
const flushParagraph = () => {
if (paragraph.length) output.push(`<p>${paragraph.map(inline).join('<br>')}</p>`);
paragraph = [];
};
const flushQuote = () => {
if (quote.length) output.push(`<blockquote>${quote.join('<br>')}</blockquote>`);
quote = [];
};
const flushList = () => {
if (listType) output.push(`<${listType}>${listItems.map((item) => `<li>${item}</li>`).join('')}</${listType}>`);
listType = null;
listItems = [];
};
const flushBlocks = () => {
flushParagraph();
flushQuote();
flushList();
};
for (const line of lines) {
if (/^```/.test(line)) {
flushBlocks();
if (code) {
output.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
code = null;
} else {
code = [];
}
continue;
}
if (code) {
code.push(line);
continue;
}
if (!line.trim()) {
flushBlocks();
continue;
}
const heading = line.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
flushBlocks();
output.push(`<h${heading[1].length}>${inline(heading[2])}</h${heading[1].length}>`);
continue;
}
const quoted = line.match(/^>\s?(.*)$/);
if (quoted) {
flushParagraph();
flushList();
quote.push(inline(quoted[1]));
continue;
}
const unordered = line.match(/^\s*[-+*]\s+(.+)$/);
const ordered = line.match(/^\s*\d+\.\s+(.+)$/);
if (unordered || ordered) {
flushParagraph();
flushQuote();
const type = ordered ? 'ol' : 'ul';
if (listType !== type) {
flushList();
listType = type;
}
listItems.push(inline((ordered || unordered)![1]));
continue;
}
flushQuote();
flushList();
paragraph.push(line);
}
if (code) output.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
flushBlocks();
return output.join('') || `<p>${escapeHtml(String(value || ''))}</p>`;
};
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import ts from 'typescript';
const source = await readFile(new URL('../src/utils/markdown.ts', import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 }
}).outputText;
const { renderMarkdown } = await import(`data:text/javascript,${encodeURIComponent(compiled)}`);
test('全网答案 Markdown 只渲染白名单标签并转义原始 HTML', () => {
const html = renderMarkdown('### 标题\n**加粗** 和 *强调*,含 [官网](https://example.com)。\n\n- 一项\n- 二项\n\n> 引用\n\n`行内`\n\n```\n<script>alert(1)</script>\n```');
assert.match(html, /<h3>标题<\/h3>/);
assert.match(html, /<strong>加粗<\/strong>/);
assert.match(html, /<em>强调<\/em>/);
assert.match(html, /href="https:\/\/example\.com\//);
assert.match(html, /<ul><li>一项<\/li><li>二项<\/li><\/ul>/);
assert.match(html, /<blockquote>引用<\/blockquote>/);
assert.match(html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/);
assert.equal(renderMarkdown('普通文本'), '<p>普通文本</p>');
assert.doesNotMatch(renderMarkdown('[危险](javascript:alert(1))'), /href=/);
});