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:
3 files changed
+102
-5
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user