feat(doc): strip server-rejected unsafe chars on markdown write path (#465)

The doc write boundary only stripped a fixed dangerous-Unicode set, and only
on the JSONML path. C0 control characters (except tab/newline), DEL (0x7F),
and a few zero-width / line-separator codepoints still reached the server,
where RejectControlChars rejects them — so doc create/update failed on content
that pasted in such characters (common with LLM-generated or copy-pasted text).

- Rename stripDocDangerousUnicode -> stripDocInputUnsafe and extend it to drop
  C0 controls (except \t and \n) and DEL, matching apiclient.rejectDangerousChars.
- Add U+200D, U+2028, U+2029 to the dangerous-Unicode set so it covers the
  full server-rejected range.
- Apply the strip on the markdown write path (doc create/update) and the JSONML
  node path, not just the JSONML body.
- Add unit tests for stripDocInputUnsafe.

Ported from dws-wukong (feat: 增加输入安全字符过滤功能).
This commit is contained in:
修雨 authored and GitHub committed 2026-06-16 10:56:53 +08:00
1 parent cdd8414891
commit ff33114b2c
3 files changed
+102 -5

No files matched your search

+2 -2
View File
@@ -362,7 +362,7 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command {
content = stripped
}
if content != "" {
params["markdown"] = content
params["markdown"] = stripDocInputUnsafe(content)
}
}
} else if format, err := docContentFormat(cmd, "markdown", "jsonml"); err != nil {
@@ -473,7 +473,7 @@ func newDocUpdateCommand(runner executor.Runner) *cobra.Command {
if sniffJsonMLLike(content) {
fmt.Fprintln(cmd.ErrOrStderr(), `warning: 输入内容看起来是 JSONML 结构;若要按 JSONML 解析,请加 --content-format jsonml,否则将按 markdown 解析。`)
}
params["markdown"] = content
params["markdown"] = stripDocInputUnsafe(content)
addDocIntParam(cmd, params, "index", "index")
}
return runDocTool(cmd, runner, "doc", "update_document", params)
+18 -3
View File
@@ -26,6 +26,7 @@ import (
var docDangerousUnicode = [...]rune{
0x200B,
0x200C,
0x200D,
0x200E,
0x200F,
0x202A,
@@ -33,6 +34,8 @@ var docDangerousUnicode = [...]rune{
0x202C,
0x202D,
0x202E,
0x2028,
0x2029,
0x2066,
0x2067,
0x2068,
@@ -49,8 +52,20 @@ var docDangerousSet = func() map[rune]bool {
return m
}()
func stripDocDangerousUnicode(s string) string {
// stripDocInputUnsafe removes characters that the server-side RejectControlChars
// validator (mirrored by apiclient.rejectDangerousChars) would reject:
//
// 1. C0 control characters (except tab and newline) and DEL (0x7F)
// 2. Dangerous Unicode (zero-width, Bidi controls, line/paragraph separators, BOM)
//
// It is applied at the write boundary so document content passes server
// validation instead of being rejected. Tab and newline are preserved because
// they are legitimate in document text.
func stripDocInputUnsafe(s string) string {
return strings.Map(func(r rune) rune {
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7F) {
return -1
}
if docDangerousSet[r] {
return -1
}
@@ -194,7 +209,7 @@ func prepareDocJSONMLBody(cmd *cobra.Command, raw string) (string, error) {
if err != nil {
return "", fmt.Errorf("marshal normalized jsonml: %w", err)
}
return stripDocDangerousUnicode(string(out)), nil
return stripDocInputUnsafe(string(out)), nil
}
func prepareDocJSONMLNode(cmd *cobra.Command, rawElement string) (string, error) {
@@ -247,7 +262,7 @@ func prepareDocJSONMLNode(cmd *cobra.Command, rawElement string) (string, error)
if err != nil {
return "", fmt.Errorf("marshal normalized jsonml: %w", err)
}
return string(out), nil
return stripDocInputUnsafe(string(out)), nil
}
func docEmitJSONMLFixNotes(cmd *cobra.Command, notes []string) {
@@ -0,0 +1,82 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import "testing"
// TestStripDocInputUnsafe verifies that stripDocInputUnsafe removes exactly the
// characters the server-side RejectControlChars validator rejects (C0 controls
// except tab/newline, DEL, and the dangerous-Unicode set), while leaving all
// legitimate text untouched. Offending codepoints use explicit \u / \x escapes
// so they are unambiguous in source.
func TestStripDocInputUnsafe(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "preserves plain text",
in: "正常的文档内容 with ASCII",
want: "正常的文档内容 with ASCII",
},
{
name: "keeps tab and newline",
in: "标题\n\t正文",
want: "标题\n\t正文",
},
{
name: "drops C0 controls (null, SOH, CR) and DEL",
in: "正文\x00内容\x01段落\x0d结尾\x7f",
want: "正文内容段落结尾",
},
{
name: "drops zero-width space/non-joiner/joiner",
in: "正文\u200b内容\u200c段落\u200d结尾",
want: "正文内容段落结尾",
},
{
name: "drops bidi overrides and isolates",
in: "Bidi\u202a测试\u202e结束\u2066左\u2069右",
want: "Bidi测试结束左右",
},
{
name: "drops line and paragraph separators",
in: "\u2028\u2029行段",
want: "行段",
},
{
name: "drops BOM / ZWNBSP",
in: "BOM\ufeff尾",
want: "BOM尾",
},
{
name: "drops mixed control and dangerous unicode",
in: "混合\x00测试\u200b结尾\x7f",
want: "混合测试结尾",
},
{
name: "empty string stays empty",
in: "",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := stripDocInputUnsafe(tc.in); got != tc.want {
t.Fatalf("stripDocInputUnsafe(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}