feat(web-ai): render markdown answers safely
This commit is contained in:
+1
-1
@@ -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
@@ -8,6 +8,7 @@ import org.dromara.aihr.webai.AihrWebSearchClient.SearchResult;
|
|||||||
import org.dromara.aihr.webai.AihrWebSearchClient.WebSource;
|
import org.dromara.aihr.webai.AihrWebSearchClient.WebSource;
|
||||||
import org.dromara.aihr.webai.AihrWebSearchProviderService.ProviderRequest;
|
import org.dromara.aihr.webai.AihrWebSearchProviderService.ProviderRequest;
|
||||||
import org.dromara.aihr.webai.AihrWebSearchProviderService.RuntimeProvider;
|
import org.dromara.aihr.webai.AihrWebSearchProviderService.RuntimeProvider;
|
||||||
|
import org.dromara.common.core.constant.SystemConstants;
|
||||||
import org.dromara.common.core.exception.ServiceException;
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
import org.junit.jupiter.api.Tag;
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -54,6 +55,13 @@ class AihrWebAiSecurityTest {
|
|||||||
assertFalse(mapper.writeValueAsString(request).contains("must-not-reach-logs"));
|
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
|
@Test
|
||||||
@Tag("dev")
|
@Tag("dev")
|
||||||
void searchEndpointRejectsLocalAndPrivateNetworks() {
|
void searchEndpointRejectsLocalAndPrivateNetworks() {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
<view class="bubble master">
|
<view class="bubble master">
|
||||||
<text v-if="msg.result.status === 'NO_ANSWER'" class="bubble-noevidence">已找到公开来源,但外部服务未返回可核验答案,请直接查看来源。</text>
|
<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-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.redacted" class="bubble-redacted">已自动隐藏问题中的个人或门禁信息</text>
|
||||||
<text v-if="msg.result.warning" class="bubble-warning">{{ msg.result.warning }}</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 { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||||
import { chooseSpeechAudio, createSpeechPlaybackController, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
import { chooseSpeechAudio, createSpeechPlaybackController, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||||
import { getWebAiCapabilities, queryWebAi } from '@/services/web-ai';
|
import { getWebAiCapabilities, queryWebAi } from '@/services/web-ai';
|
||||||
|
import { renderMarkdown } from '@/utils/markdown';
|
||||||
|
|
||||||
const path = '/pages/user/web-ai/index';
|
const path = '/pages/user/web-ai/index';
|
||||||
|
|
||||||
@@ -505,6 +506,76 @@ onUnload(() => {
|
|||||||
white-space: pre-wrap;
|
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 {
|
.bubble-subnote {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (char) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
}[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>`;
|
||||||
|
};
|
||||||
@@ -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, /<script>alert\(1\)<\/script>/);
|
||||||
|
assert.equal(renderMarkdown('普通文本'), '<p>普通文本</p>');
|
||||||
|
assert.doesNotMatch(renderMarkdown('[危险](javascript:alert(1))'), /href=/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user