@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
96
pkg/prosemirror/sanitize.go
Normal file
96
pkg/prosemirror/sanitize.go
Normal 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
|
||||
}
|
||||
84
pkg/prosemirror/sanitize_test.go
Normal file
84
pkg/prosemirror/sanitize_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user