Sanitize upon saving

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-30 17:52:25 +04:00
parent 62e68dfb6f
commit f3982f23f9
23 changed files with 471 additions and 356 deletions

View File

@@ -18,7 +18,9 @@ import (
"bytes"
"fmt"
"html"
"net/url"
"strconv"
"strings"
)
// RenderHTML renders a ProseMirror document node tree to an HTML string.
@@ -192,7 +194,7 @@ func openMark(buf *bytes.Buffer, m Mark) error {
return fmt.Errorf("cannot render link mark: %w", err)
}
buf.WriteString("<a")
writeAttr(buf, "href", attrs.Href)
writeAttr(buf, "href", safeLinkHref(attrs.Href))
if attrs.Target != nil {
writeAttr(buf, "target", *attrs.Target)
}
@@ -266,3 +268,39 @@ func writeAttr(buf *bytes.Buffer, name, value string) {
buf.WriteString(html.EscapeString(value))
buf.WriteByte('"')
}
// safeLinkHref returns a value safe to use in link mark attrs and to emit in an
// HTML href attribute. URLs with disallowed schemes (for example javascript: or
// data:) are replaced with "#" so escaped text content cannot be combined with an
// executable URL.
func safeLinkHref(href string) string {
href = strings.TrimSpace(href)
if href == "" {
return "#"
}
if href[0] == '#' {
return href
}
if strings.HasPrefix(href, "/") {
if len(href) > 1 && (href[1] == '/' || href[1] == '\\') {
return "#"
}
return href
}
u, err := url.Parse(href)
if err != nil {
return "#"
}
if u.Scheme != "" {
switch strings.ToLower(u.Scheme) {
case "http", "https", "mailto", "tel":
return href
default:
return "#"
}
}
if u.Host != "" {
return "#"
}
return href
}

View File

@@ -561,7 +561,7 @@ func (c *htmlBlockConverter) convertAnchor(n *html.Node) ([]Node, error) {
if t := attrVal(n, "title"); t != "" {
title = &t
}
attrs, err := json.Marshal(LinkAttrs{Href: href, Title: title})
attrs, err := json.Marshal(LinkAttrs{Href: safeLinkHref(href), Title: title})
if err != nil {
return nil, fmt.Errorf("cannot marshal link attrs: %w", err)
}

View File

@@ -16,6 +16,8 @@ package prosemirror
import (
"encoding/json"
"fmt"
"html"
"os"
"testing"
@@ -185,6 +187,49 @@ func TestRenderHTML_LinkMinimalAttrs(t *testing.T) {
assert.Equal(t, `<a href="https://example.com">hi</a>`, got)
}
func TestRenderHTML_LinkSanitizesDangerousHrefs(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
href string
wantHref string
}{
{name: "javascript scheme", href: `javascript:alert(1)`, wantHref: `#`},
{name: "javascript scheme case insensitive", href: `javaScript:alert(1)`, wantHref: `#`},
{name: "data html", href: `data:text/html,<script>alert(1)</script>`, wantHref: `#`},
{name: "protocol-relative", href: `//evil.example/phish`, wantHref: `#`},
{name: "path with leading slash-slash", href: `//not-a-path`, wantHref: `#`},
{name: "empty href", href: ``, wantHref: `#`},
{name: "fragment only", href: `#section`, wantHref: `#section`},
{name: "relative path", href: `docs/page`, wantHref: `docs/page`},
{name: "absolute path", href: `/app/foo`, wantHref: `/app/foo`},
{name: "mailto", href: `mailto:user@example.com`, wantHref: `mailto:user@example.com`},
{name: "tel", href: `tel:+15551212`, wantHref: `tel:+15551212`},
{name: "https preserved", href: `https://example.com/x`, wantHref: `https://example.com/x`},
} {
t.Run(
tc.name,
func(t *testing.T) {
t.Parallel()
hrefJSON, err := json.Marshal(tc.href)
require.NoError(t, err)
raw := fmt.Sprintf(
`{"type":"text","marks":[{"type":"link","attrs":{"href":%s,"target":null,"rel":null,"class":null,"title":null}}],"text":"x"}`,
string(hrefJSON),
)
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
got, err := RenderHTML(n)
require.NoError(t, err)
want := fmt.Sprintf(`<a href="%s">x</a>`, html.EscapeString(tc.wantHref))
assert.Equal(t, want, got)
},
)
}
}
func TestRenderHTML_Image(t *testing.T) {
t.Parallel()

View File

@@ -426,7 +426,7 @@ func (c *converter) convertCodeSpan(n ast.Node) ([]Node, error) {
func (c *converter) convertLink(n *ast.Link) ([]Node, error) {
linkAttrs := LinkAttrs{
Href: string(n.Destination),
Href: safeLinkHref(string(n.Destination)),
}
if n.Title != nil {
@@ -452,7 +452,7 @@ func (c *converter) convertLink(n *ast.Link) ([]Node, error) {
func (c *converter) convertAutoLink(n *ast.AutoLink) ([]Node, error) {
url := string(n.URL(c.source))
linkAttrs := LinkAttrs{Href: url}
linkAttrs := LinkAttrs{Href: safeLinkHref(url)}
attrs, err := json.Marshal(linkAttrs)
if err != nil {
return nil, fmt.Errorf("cannot marshal link attrs: %w", err)

View File

@@ -205,6 +205,33 @@ func TestParseMarkdown_Link(t *testing.T) {
assert.Equal(t, "https://example.com", linkAttrs.Href)
}
func TestParseMarkdown_LinkSanitizesDangerousHrefs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
markdown string
wantHref string
}{
{name: "javascript scheme", markdown: `[x](javascript:alert(1))`, wantHref: `#`},
{name: "data html", markdown: `[x](data:text/html,<script>alert(1)</script>)`, wantHref: `#`},
{name: "protocol-relative", markdown: `[x](//evil.example/phish)`, wantHref: `#`},
{name: "https preserved", markdown: `[x](https://example.com/y)`, wantHref: `https://example.com/y`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown(tt.markdown)
require.NoError(t, err)
txt := doc.Content[0].Content[0]
linkAttrs, err := txt.Marks[0].LinkAttrs()
require.NoError(t, err)
assert.Equal(t, tt.wantHref, linkAttrs.Href)
})
}
}
func TestParseMarkdown_Image(t *testing.T) {
t.Parallel()

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package prosemirror
import (
"encoding/json"
"fmt"
"strings"
)
// ValidateDocumentContentJSON returns nil if s is empty or whitespace-only.
// Otherwise s must be valid ProseMirror JSON whose root node has type "doc".
func ValidateDocumentContentJSON(s string) error {
if strings.TrimSpace(s) == "" {
return nil
}
_, err := parseDocRoot(s)
return err
}
func parseDocRoot(s string) (Node, error) {
n, err := Parse(s)
if err != nil {
return Node{}, fmt.Errorf("cannot parse document content as ProseMirror JSON: %w", err)
}
if n.Type != NodeDoc {
return Node{}, fmt.Errorf("document content root must be type %q", NodeDoc)
}
return n, nil
}
// SanitizeDocumentJSON parses a ProseMirror/Tiptap JSON document, replaces
// unsafe link mark href values using the same rules as RenderHTML, and
// re-serializes the document. Whitespace-only input is returned unchanged.
// Non-empty content must be valid JSON whose root node has type "doc".
func SanitizeDocumentJSON(s string) (string, error) {
if strings.TrimSpace(s) == "" {
return s, nil
}
n, err := parseDocRoot(s)
if err != nil {
return "", err
}
sanitizeNode(&n)
out, err := json.Marshal(n)
if err != nil {
return "", fmt.Errorf("cannot marshal sanitized document: %w", err)
}
return string(out), nil
}
func sanitizeNode(n *Node) {
for i := range n.Marks {
sanitizeLinkMark(&n.Marks[i])
}
for i := range n.Content {
sanitizeNode(&n.Content[i])
}
}
func sanitizeLinkMark(m *Mark) {
if m.Type != MarkLink {
return
}
attrs, err := m.LinkAttrs()
if err != nil {
m.Attrs = []byte(`{"href":"#"}`)
return
}
attrs.Href = safeLinkHref(attrs.Href)
raw, err := json.Marshal(attrs)
if err != nil {
m.Attrs = []byte(`{"href":"#"}`)
return
}
m.Attrs = raw
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package prosemirror
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSanitizeDocumentJSON_EmptyUnchanged(t *testing.T) {
t.Parallel()
got, err := SanitizeDocumentJSON("")
require.NoError(t, err)
assert.Equal(t, "", got)
got, err = SanitizeDocumentJSON(" ")
require.NoError(t, err)
assert.Equal(t, " ", got)
}
func TestSanitizeDocumentJSON_NonJSONError(t *testing.T) {
t.Parallel()
_, err := SanitizeDocumentJSON("plain text is not valid document JSON")
require.Error(t, err)
}
func TestSanitizeDocumentJSON_NonDocRootError(t *testing.T) {
t.Parallel()
_, err := SanitizeDocumentJSON(`{"type":"paragraph","content":[]}`)
require.Error(t, err)
}
func TestSanitizeDocumentJSON_LinkHref(t *testing.T) {
t.Parallel()
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"javascript:alert(1)","target":"_blank"}}],"text":"click"}]}]}`
out, err := SanitizeDocumentJSON(raw)
require.NoError(t, err)
var doc Node
require.NoError(t, json.Unmarshal([]byte(out), &doc))
txt := doc.Content[0].Content[0]
require.Len(t, txt.Marks, 1)
attrs, err := txt.Marks[0].LinkAttrs()
require.NoError(t, err)
assert.Equal(t, "#", attrs.Href)
require.NotNil(t, attrs.Target)
assert.Equal(t, "_blank", *attrs.Target)
}
func TestSanitizeDocumentJSON_PreservesSafeHref(t *testing.T) {
t.Parallel()
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}],"text":"ok"}]}]}`
out, err := SanitizeDocumentJSON(raw)
require.NoError(t, err)
var doc Node
require.NoError(t, json.Unmarshal([]byte(out), &doc))
txt := doc.Content[0].Content[0]
attrs, err := txt.Marks[0].LinkAttrs()
require.NoError(t, err)
assert.Equal(t, "https://example.com", attrs.Href)
}