fix: 修复学生代码窃取 token、提示词链 XSS 和打分没有上限的问题
- 运行提交代码的 iframe(编辑器预览、随机评分弹窗、/submission/:id、全屏预览) 加上 sandbox,不给 allow-same-origin,代码读不到 localStorage 里的 token - 提示词和 AI 回复的 markdown 渲染时转义原始 HTML,链接只放行 http(s)/mailto; ChainModal 也改用这个渲染函数 - 打分限定 1-5 分(超出返回 422),不能给自己的提交打分 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4014aaabbf
commit
73788c4595
8 files changed
+75
-31
No files matched your search
@@ -14,7 +14,11 @@
|
|||||||
{{ current.task_title }} · 提交者 {{ current.username }}
|
{{ current.task_title }} · 提交者 {{ current.username }}
|
||||||
</n-text>
|
</n-text>
|
||||||
<div class="preview-wrapper">
|
<div class="preview-wrapper">
|
||||||
<iframe class="preview-iframe" :srcdoc="previewContent"></iframe>
|
<iframe
|
||||||
|
class="preview-iframe"
|
||||||
|
:sandbox="PREVIEW_SANDBOX"
|
||||||
|
:srcdoc="previewContent"
|
||||||
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
<div class="rate-row">
|
<div class="rate-row">
|
||||||
<n-rate
|
<n-rate
|
||||||
@@ -38,7 +42,10 @@ import { ref, watch, onUnmounted } from "vue"
|
|||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { Submission } from "../../api"
|
import { Submission } from "../../api"
|
||||||
import { streaming } from "../../store/prompt"
|
import { streaming } from "../../store/prompt"
|
||||||
import { buildPreviewDocument } from "../../utils/previewDocument"
|
import {
|
||||||
|
buildPreviewDocument,
|
||||||
|
PREVIEW_SANDBOX,
|
||||||
|
} from "../../utils/previewDocument"
|
||||||
import type { RandomRatingItem } from "../../utils/type"
|
import type { RandomRatingItem } from "../../utils/type"
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|||||||
@@ -42,7 +42,11 @@
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<div class="iframe-wrapper" :style="iframeWrapperStyle">
|
<div class="iframe-wrapper" :style="iframeWrapperStyle">
|
||||||
<iframe class="iframe" :srcdoc="previewContent"></iframe>
|
<iframe
|
||||||
|
class="iframe"
|
||||||
|
:sandbox="PREVIEW_SANDBOX"
|
||||||
|
:srcdoc="previewContent"
|
||||||
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@@ -54,7 +58,10 @@ import { submission } from "../../store/submission"
|
|||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import copy from "copy-text-to-clipboard"
|
import copy from "copy-text-to-clipboard"
|
||||||
import { buildPreviewDocument } from "../../utils/previewDocument"
|
import {
|
||||||
|
buildPreviewDocument,
|
||||||
|
PREVIEW_SANDBOX,
|
||||||
|
} from "../../utils/previewDocument"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
html: string
|
html: string
|
||||||
@@ -145,9 +152,14 @@ function open() {
|
|||||||
} else {
|
} else {
|
||||||
const newTab = window.open("about:blank", "_blank")
|
const newTab = window.open("about:blank", "_blank")
|
||||||
if (!newTab) return
|
if (!newTab) return
|
||||||
newTab.document.open()
|
// 新标签页和本站同源,代码要放进带 sandbox 的 iframe 里跑
|
||||||
newTab.document.write(getContent())
|
const doc = newTab.document
|
||||||
newTab.document.close()
|
doc.open()
|
||||||
|
doc.write(
|
||||||
|
`<!DOCTYPE html><title>预览</title><style>html,body,iframe{margin:0;width:100%;height:100%;border:0;display:block}</style><iframe sandbox="${PREVIEW_SANDBOX}"></iframe>`,
|
||||||
|
)
|
||||||
|
doc.close()
|
||||||
|
doc.querySelector("iframe")!.srcdoc = getContent()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -240,7 +240,7 @@
|
|||||||
import { computed, ref, watch } from "vue"
|
import { computed, ref, watch } from "vue"
|
||||||
import { NPopconfirm, NButton, useMessage } from "naive-ui"
|
import { NPopconfirm, NButton, useMessage } from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { marked } from "marked"
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
import { Prompt, Submission } from "../../api"
|
import { Prompt, Submission } from "../../api"
|
||||||
import type { PromptRound } from "../../utils/type"
|
import type { PromptRound } from "../../utils/type"
|
||||||
import { user, roleSuper } from "../../store/user"
|
import { user, roleSuper } from "../../store/user"
|
||||||
@@ -301,10 +301,6 @@ const selectedPageHtml = computed(() => {
|
|||||||
return `<!DOCTYPE html><html><head><meta charset="utf-8">${style}</head><body>${round.html}${script}</body></html>`
|
return `<!DOCTYPE html><html><head><meta charset="utf-8">${style}</head><body>${round.html}${script}</body></html>`
|
||||||
})
|
})
|
||||||
|
|
||||||
function renderMarkdown(text: string): string {
|
|
||||||
return marked.parse(text) as string
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPromptLong(text: string): boolean {
|
function isPromptLong(text: string): boolean {
|
||||||
return text.length > 220 || text.split(/\r?\n/).length > 4
|
return text.length > 220 || text.split(/\r?\n/).length > 4
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,30 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, useTemplateRef } from "vue"
|
import { onMounted, ref } from "vue"
|
||||||
import { Submission } from "../api"
|
import { Submission } from "../api"
|
||||||
import type { SubmissionAll } from "../utils/type"
|
import type { SubmissionAll } from "../utils/type"
|
||||||
import { buildPreviewDocument } from "../utils/previewDocument"
|
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../utils/previewDocument"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const iframe = useTemplateRef<HTMLIFrameElement>("iframe")
|
const content = ref("")
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const submission: SubmissionAll = await Submission.get(props.id)
|
const submission: SubmissionAll = await Submission.get(props.id)
|
||||||
Submission.incrementView(props.id)
|
Submission.incrementView(props.id)
|
||||||
|
content.value = buildPreviewDocument({
|
||||||
if (!iframe.value) return
|
|
||||||
const doc = iframe.value.contentDocument
|
|
||||||
if (doc) {
|
|
||||||
doc.open()
|
|
||||||
doc.write(
|
|
||||||
buildPreviewDocument({
|
|
||||||
html: submission.html,
|
html: submission.html,
|
||||||
css: submission.css,
|
css: submission.css,
|
||||||
js: submission.js,
|
js: submission.js,
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
doc.close()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(init)
|
onMounted(init)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<iframe class="iframe" ref="iframe"></iframe>
|
<iframe class="iframe" :sandbox="PREVIEW_SANDBOX" :srcdoc="content"></iframe>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.iframe {
|
.iframe {
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
import { marked } from "marked"
|
import { Marked } from "marked"
|
||||||
|
|
||||||
|
// 提示词和 AI 回复是用户可控的内容,渲染进 v-html 前必须去掉可执行的部分:
|
||||||
|
// 原始 HTML 一律转义成文本,链接和图片只放行 http(s)/mailto 和相对地址
|
||||||
|
const escapeHtml = (s: string) =>
|
||||||
|
s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
|
||||||
|
const isSafeUrl = (href: string) =>
|
||||||
|
!/^\s*[a-z][a-z0-9+.-]*:/i.test(href) || /^\s*(https?|mailto):/i.test(href)
|
||||||
|
|
||||||
|
const safeMarked = new Marked({
|
||||||
|
renderer: {
|
||||||
|
html({ text }) {
|
||||||
|
return escapeHtml(text)
|
||||||
|
},
|
||||||
|
link({ href, tokens }) {
|
||||||
|
if (isSafeUrl(href)) return false
|
||||||
|
return this.parser.parseInline(tokens)
|
||||||
|
},
|
||||||
|
image({ href, text }) {
|
||||||
|
if (isSafeUrl(href)) return false
|
||||||
|
return escapeHtml(text)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export function renderMarkdown(text: string): string {
|
export function renderMarkdown(text: string): string {
|
||||||
return marked.parse(text) as string
|
return safeMarked.parse(text) as string
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
// 预览里跑的是用户写的代码(可能是别人的提交),不能给 allow-same-origin,
|
||||||
|
// 否则它和本站同源,能读 localStorage 里的登录 token
|
||||||
|
export const PREVIEW_SANDBOX =
|
||||||
|
"allow-scripts allow-modals allow-forms allow-popups allow-pointer-lock"
|
||||||
|
|
||||||
interface PreviewDocumentOptions {
|
interface PreviewDocumentOptions {
|
||||||
html: string
|
html: string
|
||||||
css: string
|
css: string
|
||||||
|
|||||||
@@ -530,9 +530,10 @@ router.post("/:id/view", (c) => {
|
|||||||
router.put("/:id/score", async (c) => {
|
router.put("/:id/score", async (c) => {
|
||||||
const user = requireUser(c)
|
const user = requireUser(c)
|
||||||
const id = uuidParam(c, "id")
|
const id = uuidParam(c, "id")
|
||||||
const { score } = await body(c, z.object({ score: z.number().int() }))
|
const { score } = await body(c, z.object({ score: z.number().int().max(5) }))
|
||||||
if (score <= 0) throw new HttpError(400, "分数不能为零")
|
if (score <= 0) throw new HttpError(400, "分数不能为零")
|
||||||
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
|
const sub = orNotFound(get<{ user_id: number }>(`SELECT user_id FROM submission_submission WHERE id = $id`, { id }))
|
||||||
|
if (sub.user_id === user.id) throw new HttpError(400, "不能给自己的提交打分")
|
||||||
|
|
||||||
const exists = get(`SELECT 1 FROM submission_rating WHERE user_id = $user_id AND submission_id = $id`, {
|
const exists = get(`SELECT 1 FROM submission_rating WHERE user_id = $user_id AND submission_id = $id`, {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
|||||||
@@ -136,6 +136,10 @@ describe("评分", () => {
|
|||||||
const again = await (await client("s0"))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 1 })
|
const again = await (await client("s0"))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 1 })
|
||||||
expect(again.json.message).toBe("你已经给这个提交打过分了")
|
expect(again.json.message).toBe("你已经给这个提交打过分了")
|
||||||
expect((await (await client("s0"))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 0 })).status).toBe(400)
|
expect((await (await client("s0"))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 0 })).status).toBe(400)
|
||||||
|
expect((await (await client("s1"))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 6 })).status).toBe(422)
|
||||||
|
const self = await (await client("author"))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 5 })
|
||||||
|
expect(self.status).toBe(400)
|
||||||
|
expect(all(`SELECT 1 FROM submission_rating WHERE user_id = $id`, { id: author })).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user