diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index 29fba2e5..4c68b914 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -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) diff --git a/internal/helpers/doc_jsonml.go b/internal/helpers/doc_jsonml.go index b3b8c92b..42fc0856 100644 --- a/internal/helpers/doc_jsonml.go +++ b/internal/helpers/doc_jsonml.go @@ -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) { diff --git a/internal/helpers/doc_strip_input_unsafe_test.go b/internal/helpers/doc_strip_input_unsafe_test.go new file mode 100644 index 00000000..20882727 --- /dev/null +++ b/internal/helpers/doc_strip_input_unsafe_test.go @@ -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) + } + }) + } +}