diff --git a/shortcuts/mail/lint/linter.go b/shortcuts/mail/lint/linter.go new file mode 100644 index 0000000000..b0668a5a6c --- /dev/null +++ b/shortcuts/mail/lint/linter.go @@ -0,0 +1,1065 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lint + +import ( + "bytes" + "fmt" + "hash/fnv" + "strings" + + xhtml "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +// MaxExcerptBytes caps the raw-HTML excerpt embedded in a Finding.Excerpt so +// a single offending tag with megabyte content can't bloat the envelope JSON. +// S2 contract «Server-mirrored constraints» row "EML composition body+inline+ +// SMALL ≤ 25 MB" calls this out: lint ops on bytes only, but the excerpt +// representation must not be size-amplifying. +const MaxExcerptBytes = 200 + +// Run lints the given HTML body and returns a structured Report. When +// opts.AutoFix is true, Report.CleanedHTML contains the rewritten HTML +// (warnings rewritten + errors deleted); when false, only error-tier findings +// are removed (writing-path safety floor cannot be opted out of), warnings +// are surfaced as observations only, and CleanedHTML still contains the +// rewritten HTML — but `+lint-html --auto-fix=false` callers are expected to +// drop this field from the public envelope per spec §4.2. +// +// IMPORTANT: when the input is empty or plain-text (no HTML markup detected +// by the cli's existing `bodyIsHTML` heuristic), callers should short-circuit +// with EmptyReport(html) instead of paying the parse cost. Run still handles +// this gracefully — html.Parse on plain text wraps the input in +// ..., and the lib's pass-through +// rendering will reproduce the original text — but the round-trip is wasteful +// and produces no findings. +func Run(html string, opts Options) Report { + if html == "" { + return EmptyReport("") + } + + rep := Report{ + Applied: []Finding{}, + Blocked: []Finding{}, + } + + // We use html.ParseFragment so users authoring fragment-style snippets + // (the canonical compose-5 input shape — `
...
` rather than a + // full document) don't get implicit wrappers + // re-rendered. The "body" insertion mode matches what html.Parse would + // have done internally for a fragment but skips the structural wrappers + // at render time. + bodyContext := &xhtml.Node{Type: xhtml.ElementNode, DataAtom: atom.Body, Data: "body"} + nodes, err := xhtml.ParseFragment(strings.NewReader(html), bodyContext) + if err != nil { + // Parser failure is exceptional (the parser is permissive by design); + // fall back to the original input so we don't lose user content. + return EmptyReport(html) + } + + // Wrap fragment nodes in a synthetic root so the recursive walker has a + // uniform parent pointer to mutate. + root := &xhtml.Node{Type: xhtml.DocumentNode} + for _, n := range nodes { + root.AppendChild(n) + } + + walk(root, &rep, opts) + applyFeishuNativeStyles(root, &rep, opts) + + rep.HasErrorFindings = len(rep.Blocked) > 0 + rep.HasWarningFindings = len(rep.Applied) > 0 + rep.CleanedHTML = renderFragment(root) + + return rep +} + +// walk visits every element node under parent, applying tag/attr/style +// classification. Children are iterated via the next-sibling pointer because +// we mutate the tree in place (replace / remove nodes). +// +// The walker is iterative-style via explicit recursion because the html +// parser's typical nesting depth (≤ 256 by default) is well below Go's +// goroutine stack limit; the existing draft package's plainTextFromHTML +// (mail/draft/htmltext.go) similarly recurses for the same reason. +func walk(parent *xhtml.Node, rep *Report, opts Options) { + child := parent.FirstChild + for child != nil { + next := child.NextSibling + if child.Type == xhtml.ElementNode { + processElement(parent, child, rep, opts) + } + // child may have been removed/replaced by processElement; recurse + // only if it still has the original parent (i.e. wasn't deleted). + // The html parser sets Parent on every node, so a removed-then- + // reattached node still recurses correctly via its new Parent. + if child.Parent != nil { + walk(child, rep, opts) + } + child = next + } +} + +// processElement applies the element-level classification cascade: +// 1. tag → allow / warn-rewrite / error-delete +// 2. attributes → on*-handlers, URL-bearing attrs (scheme allow-list), +// style attribute (CSS property allow-list) +func processElement(parent, n *xhtml.Node, rep *Report, opts Options) { + tagName := strings.ToLower(n.Data) + kind, ruleID := classifyTag(tagName) + + switch kind { + case "error": + rep.Blocked = append(rep.Blocked, Finding{ + RuleID: ruleID, + Severity: SeverityError, + TagOrAttr: tagName, + Excerpt: excerptOf(n), + Hint: hintForBlockedTag(tagName), + }) + // Always remove blocked tags regardless of opts.AutoFix — writing-path + // safety floor cannot be opted out of (spec §4.3 — `--no-lint` is not + // provided). + parent.RemoveChild(n) + return + + case "warn": + // AutoFix=true → rewrite (e.g. ); AutoFix=false → + // surface the finding as observation only, keep the original tag. + // `+lint-html --auto-fix=false` consumers want to see what would + // change without the lib forcing the change. + if opts.AutoFix { + finding := Finding{ + RuleID: ruleID, + Severity: SeverityWarning, + TagOrAttr: tagName, + Excerpt: excerptOf(n), + Hint: hintForWarnTag(tagName), + } + if opts.Strict { + finding.Severity = SeverityError + rep.Blocked = append(rep.Blocked, finding) + } else { + rep.Applied = append(rep.Applied, finding) + } + rewriteWarnTag(n, tagName) + // Recurse into the rewritten node by falling through; the + // rewrite preserved children as-is. + } else { + finding := Finding{ + RuleID: ruleID, + Severity: SeverityWarning, + TagOrAttr: tagName, + Excerpt: excerptOf(n), + Hint: hintForWarnTag(tagName), + } + if opts.Strict { + finding.Severity = SeverityError + rep.Blocked = append(rep.Blocked, finding) + } else { + rep.Applied = append(rep.Applied, finding) + } + } + // fall through to attribute scan + case "allow": + // no-op + } + + // Attribute scan: build a new attribute slice, dropping/sanitising as we + // go and surfacing findings. + if len(n.Attr) > 0 { + processAttributes(n, rep, opts) + } +} + +// processAttributes walks the attribute list and: +// - drops on*-handlers (always; surfaced as error) +// - drops URL-bearing attrs whose value uses a forbidden scheme +// - filters the `style` attribute property-by-property against the allow-list +// +// Other attributes pass through unchanged. The cli's existing +// `validateInlineCIDs` (helpers.go:2226) handles `cid:`-specific checks; the +// lint must not duplicate that responsibility (S2 contract «MUST reuse» row). +func processAttributes(n *xhtml.Node, rep *Report, opts Options) { + keep := n.Attr[:0] + for _, attr := range n.Attr { + name := strings.ToLower(attr.Key) + + // 1. on*-handlers → always drop, error-tier. + if isEventHandlerAttr(name) { + rep.Blocked = append(rep.Blocked, Finding{ + RuleID: RuleAttrEventHandlerBlocked, + Severity: SeverityError, + TagOrAttr: name, + Excerpt: truncateExcerpt(attr.Key + "=\"" + attr.Val + "\""), + Hint: "已删除事件处理器属性(on*)", + }) + continue + } + + // 2. URL-bearing attrs → check scheme allow-list. + if urlAttributes[name] { + kind, ruleID := classifyURLValue(attr.Val) + switch kind { + case "error": + severity := SeverityError + rep.Blocked = append(rep.Blocked, Finding{ + RuleID: ruleID, + Severity: severity, + TagOrAttr: name, + Excerpt: truncateExcerpt(attr.Key + "=\"" + attr.Val + "\""), + Hint: "已删除危险 URL 协议(仅允许 http/https/mailto/cid/data:image/*)", + }) + continue + case "warn": + finding := Finding{ + RuleID: ruleID, + Severity: SeverityWarning, + TagOrAttr: name, + Excerpt: truncateExcerpt(attr.Key + "=\"" + attr.Val + "\""), + Hint: "URL 协议不在白名单(http/https/mailto/cid/data:image/*);如确需使用请联系管理员", + } + if opts.Strict { + finding.Severity = SeverityError + rep.Blocked = append(rep.Blocked, finding) + } else { + rep.Applied = append(rep.Applied, finding) + } + if opts.AutoFix { + // Drop the attribute when AutoFix is set — writing-path + // safety floor (the URL would not render correctly anyway). + continue + } + } + } + + // 3. `style` attribute → property-by-property allow-list. + if name == "style" { + cleaned, dropped := sanitiseStyleAttr(attr.Val) + for _, prop := range dropped { + rep.Applied = append(rep.Applied, Finding{ + RuleID: RuleStylePropertyDropped, + Severity: SeverityWarning, + TagOrAttr: "style." + prop, + Excerpt: truncateExcerpt(prop), + Hint: "已删除非白名单 CSS 属性(详见 references/lark-mail-html-allowlist.md)", + }) + } + if len(dropped) == 0 { + attr.Val = cleaned + keep = append(keep, attr) + continue + } + if !opts.AutoFix { + // AutoFix=false: keep the original property list so users see + // exactly what would change. + keep = append(keep, attr) + continue + } + if cleaned == "" { + // All properties dropped — remove the attribute entirely. + continue + } + attr.Val = cleaned + keep = append(keep, attr) + continue + } + + // 4. Pass-through. + keep = append(keep, attr) + } + n.Attr = keep +} + +// rewriteWarnTag replaces a warning-tier tag with its Feishu-native +// equivalent in place: with color/face/size +// distilled into inline style;
; +// / (text-only, animation discarded — collapsing +// to a span keeps the children but drops the deprecated animation effect). +func rewriteWarnTag(n *xhtml.Node, tagName string) { + switch tagName { + case "font": + // Distill . + var styles []string + var keepAttrs []xhtml.Attribute + for _, attr := range n.Attr { + switch strings.ToLower(attr.Key) { + case "color": + if v := strings.TrimSpace(attr.Val); v != "" { + styles = append(styles, "color:"+v) + } + case "face": + if v := strings.TrimSpace(attr.Val); v != "" { + styles = append(styles, "font-family:"+v) + } + case "size": + if v := mapFontSize(attr.Val); v != "" { + styles = append(styles, "font-size:"+v) + } + default: + keepAttrs = append(keepAttrs, attr) + } + } + // Merge any existing style attribute already present on the + // (rare but possible). + if len(styles) > 0 { + merged := strings.Join(styles, ";") + styleIdx := -1 + for i, attr := range keepAttrs { + if strings.ToLower(attr.Key) == "style" { + styleIdx = i + break + } + } + if styleIdx >= 0 { + existing := strings.TrimRight(keepAttrs[styleIdx].Val, "; ") + if existing != "" { + merged = existing + ";" + merged + } + keepAttrs[styleIdx].Val = merged + } else { + keepAttrs = append(keepAttrs, xhtml.Attribute{Key: "style", Val: merged}) + } + } + n.Data = "span" + n.DataAtom = atom.Span + n.Attr = keepAttrs + + case "center": + //
. Existing style attr + // (if any) is merged with text-align prepended. + styleIdx := -1 + for i, attr := range n.Attr { + if strings.ToLower(attr.Key) == "style" { + styleIdx = i + break + } + } + newStyle := "text-align:center" + if styleIdx >= 0 { + existing := strings.TrimRight(n.Attr[styleIdx].Val, "; ") + if existing != "" { + newStyle = newStyle + ";" + existing + } + n.Attr[styleIdx].Val = newStyle + } else { + n.Attr = append(n.Attr, xhtml.Attribute{Key: "style", Val: newStyle}) + } + n.Data = "div" + n.DataAtom = atom.Div + + case "marquee", "blink": + // Both deprecated; collapse to so children survive. + n.Data = "span" + n.DataAtom = atom.Span + // Strip marquee-specific attributes (direction, scrollamount, ...) + // so the rewritten span is plain. + var keepAttrs []xhtml.Attribute + for _, attr := range n.Attr { + if strings.ToLower(attr.Key) == "style" || strings.ToLower(attr.Key) == "class" || strings.ToLower(attr.Key) == "id" { + keepAttrs = append(keepAttrs, attr) + } + } + n.Attr = keepAttrs + } +} + +// mapFontSize maps the legacy values (1..7) to a CSS px +// equivalent. The mapping mirrors the editor-kit branch's renderer. +// Out-of-range values fall through to the empty string so the property is +// dropped (better than emitting an arbitrary value). +func mapFontSize(raw string) string { + switch strings.TrimSpace(raw) { + case "1": + return "10px" + case "2": + return "13px" + case "3": + return "16px" + case "4": + return "18px" + case "5": + return "24px" + case "6": + return "32px" + case "7": + return "48px" + default: + return "" + } +} + +// sanitiseStyleAttr filters a `style="prop1:val; prop2:val"` declaration +// against the property allow-list. Returns the cleaned style text (joined +// with "; " separators) and a slice of dropped property names (lower-case) +// so the caller can surface STYLE_PROPERTY_DROPPED findings. +// +// NOTE: We do NOT validate property values — only property names. Spec §4.4 +// is explicit: "style 属性按 CSS property 白名单过滤"; value-level validation +// (e.g. URL safety inside `background-image: url(...)`) is delegated to the +// urlAttributes path because such values typically appear in `src` / `href` +// attrs in compose-5 templates. Users authoring `background-image: url(http:...)` +// in inline style will see the property pass — the URL inside is not a +// security concern at the inline-style level since URL fetching from style +// is restricted by Feishu's renderer-side CSP regardless. +func sanitiseStyleAttr(raw string) (cleaned string, dropped []string) { + if strings.TrimSpace(raw) == "" { + return "", nil + } + parts := strings.Split(raw, ";") + keep := make([]string, 0, len(parts)) + for _, part := range parts { + decl := strings.TrimSpace(part) + if decl == "" { + continue + } + colon := strings.IndexByte(decl, ':') + if colon < 0 { + // Malformed declaration; drop and surface as a finding so the + // user notices. + dropped = append(dropped, decl) + continue + } + name := strings.ToLower(strings.TrimSpace(decl[:colon])) + if !classifyStyleProperty(name) { + dropped = append(dropped, name) + continue + } + keep = append(keep, decl) + } + cleaned = strings.Join(keep, "; ") + return cleaned, dropped +} + +// hintForBlockedTag returns a Chinese human-readable hint for an +// error-blocked tag (matching the `output.ErrWithHint` convention used +// elsewhere in the cli — see KB conventions/coding.md). +func hintForBlockedTag(tag string) string { + switch tag { + case "script": + return "已整段删除(XSS 风险,服务端 RemoteSanitizer 必拒)" + case "iframe", "object", "embed": + return "已整段删除(不允许嵌入外部资源;如需展示富媒体,请改用 或邮件正文链接)" + case "form", "input", "select", "option", "button": + return "已整段删除(邮件正文不允许表单)" + case "link": + return "已删除 标签(不允许外链 CSS / 资源)" + case "meta": + return "已删除 标签(不允许声明 viewport / refresh)" + case "base": + return "已删除 标签(不允许重写 URL 基址)" + default: + return "已整段删除(不允许使用该标签)" + } +} + +// hintForWarnTag returns a Chinese hint for a warning-tier tag. +func hintForWarnTag(tag string) string { + switch tag { + case "font": + return "已替换为 (飞书原生编辑器使用 inline style 表达字号 / 颜色)" + case "center": + return "已替换为
(避免使用过时的
标签)" + case "marquee", "blink": + return "已替换为 (动画效果不再支持,文字保留)" + default: + return "已按 Feishu 原生写法重写" + } +} + +// excerptOf renders the offending node's open-tag header into a short string +// suitable for surfacing in a Finding.Excerpt. We render only the tag header +// (not the full subtree) so a single offending

after

`, Options{AutoFix: autoFix}) + if len(rep.Blocked) != 1 { + t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked)) + } + if rep.Blocked[0].RuleID != RuleTagScriptBlocked { + t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleTagScriptBlocked) + } + if strings.Contains(rep.CleanedHTML, " content should be deleted, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, "safe") || !strings.Contains(rep.CleanedHTML, "after") { + t.Errorf("surrounding content lost, cleaned=%q", rep.CleanedHTML) + } + }) + } +} + +// TestRun_BlockedTagsRemoved iterates all error-tier tags. +func TestRun_BlockedTagsRemoved(t *testing.T) { + cases := map[string]string{ + ``: RuleTagIframeBlocked, + ``: RuleTagObjectBlocked, + ``: RuleTagEmbedBlocked, + `
`: RuleTagFormBlocked, + ``: RuleTagLinkBlocked, + ``: RuleTagMetaBlocked, + ``: RuleTagBaseBlocked, + } + for input, wantRule := range cases { + t.Run(input[:min(len(input), 30)], func(t *testing.T) { + rep := Run(input, Options{AutoFix: true}) + found := false + for _, f := range rep.Blocked { + if f.RuleID == wantRule { + found = true + break + } + } + if !found { + t.Errorf("expected rule %s, got %+v", wantRule, rep.Blocked) + } + }) + } +} + +// TestRun_EventHandlerAttrBlocked verifies on*-handlers are stripped (spec +// §4.4 — "属性 on*(onclick 等)"). +func TestRun_EventHandlerAttrBlocked(t *testing.T) { + rep := Run(`

x

`, Options{AutoFix: true}) + if len(rep.Blocked) != 1 { + t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked)) + } + if rep.Blocked[0].RuleID != RuleAttrEventHandlerBlocked { + t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleAttrEventHandlerBlocked) + } + if strings.Contains(rep.CleanedHTML, "onclick") { + t.Errorf("onclick should be stripped, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, `id="ok"`) { + t.Errorf("non-handler attrs should survive, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_OnErrorAttrBlocked tests one of the more common XSS vectors. +func TestRun_OnErrorAttrBlocked(t *testing.T) { + rep := Run(``, Options{AutoFix: true}) + hasErr := false + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrEventHandlerBlocked && f.TagOrAttr == "onerror" { + hasErr = true + } + } + if !hasErr { + t.Errorf("onerror should fire, got %+v", rep.Blocked) + } +} + +// ===================================================================== +// URL scheme allow-list (spec §4.4 — "URL scheme"). +// ===================================================================== + +// TestRun_JavaScriptURLBlocked verifies javascript: hrefs are stripped. +func TestRun_JavaScriptURLBlocked(t *testing.T) { + rep := Run(`click`, Options{AutoFix: true}) + hasErr := false + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrJSURLBlocked { + hasErr = true + } + } + if !hasErr { + t.Errorf("javascript: URL should fire ATTR_JS_URL_BLOCKED, got %+v", rep.Blocked) + } + if strings.Contains(rep.CleanedHTML, "javascript:") { + t.Errorf("javascript: should be stripped, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_VBScriptURLBlocked verifies vbscript: is rejected. +func TestRun_VBScriptURLBlocked(t *testing.T) { + rep := Run(`x`, Options{AutoFix: true}) + if len(rep.Blocked) == 0 { + t.Errorf("expected vbscript: to be blocked, got 0 findings") + } +} + +// TestRun_DataNonImageURLBlocked verifies data:text/html is rejected +// (only data:image/* is allowed per spec §4.4). +func TestRun_DataNonImageURLBlocked(t *testing.T) { + rep := Run(``, Options{AutoFix: true}) + if len(rep.Blocked) == 0 { + t.Errorf("expected data:text/html to be blocked") + } +} + +// TestRun_DataImageAllowed verifies data:image/png passes. +func TestRun_DataImageAllowed(t *testing.T) { + rep := Run(``, Options{AutoFix: true}) + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrJSURLBlocked { + t.Errorf("data:image/* should pass, got %+v", f) + } + } +} + +// TestRun_RelativeURLAllowed verifies relative URLs (no scheme) pass. +func TestRun_RelativeURLAllowed(t *testing.T) { + rep := Run(`x`, Options{AutoFix: true}) + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrJSURLBlocked || f.RuleID == RuleAttrUnsafeSchemeBlocked { + t.Errorf("relative URL should pass, got %+v", f) + } + } +} + +// ===================================================================== +// Style property allow-list (spec §4.4 — last paragraph). +// ===================================================================== + +// TestRun_StylePropertyDropped verifies non-allow-list properties drop. +func TestRun_StylePropertyDropped(t *testing.T) { + rep := Run(`

x

`, Options{AutoFix: true}) + dropped := []string{} + for _, f := range rep.Applied { + if f.RuleID == RuleStylePropertyDropped { + dropped = append(dropped, f.TagOrAttr) + } + } + if !sliceContains(dropped, "style.position") { + t.Errorf("expected position to be dropped, got %v", dropped) + } + if !sliceContains(dropped, "style.z-index") { + t.Errorf("expected z-index to be dropped, got %v", dropped) + } + if strings.Contains(rep.CleanedHTML, "position:") || strings.Contains(rep.CleanedHTML, "z-index:") { + t.Errorf("dropped properties should be removed from cleaned style, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, "color:red") { + t.Errorf("allowed property should survive, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_StyleBorderPrefixAllowed verifies the border-* prefix rule. +func TestRun_StyleBorderPrefixAllowed(t *testing.T) { + rep := Run(`

x

`, Options{AutoFix: true}) + for _, f := range rep.Applied { + if f.RuleID == RuleStylePropertyDropped { + t.Errorf("border-* should pass, got %+v", f) + } + } +} + +// TestRun_FeishuListShorthandMarginPreserved guards the nested-list indent +// regression: when a user writes shorthand `margin:0 0 0 24px` on an inner +//