From abef5b70da7a38b277262041e216bc4c28fe27dd Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Wed, 19 Aug 2026 22:44:40 +0800 Subject: [PATCH] fix(render): reject active-content link schemes --- render/blocknote.go | 37 ++++++++++++++++++++++++++----- render/blocknote_autolink_test.go | 21 ++++++++++++++++++ render/blocknote_test.go | 34 ++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/render/blocknote.go b/render/blocknote.go index cb4d5ed..c6ed290 100644 --- a/render/blocknote.go +++ b/render/blocknote.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "html" + "net/url" "strings" "git.dev.alexdunmow.com/block/pluginsdk/blocks" @@ -350,7 +351,7 @@ func renderBlock(ctx context.Context, block map[string]any) string { } img := fmt.Sprintf(`%s`, html.EscapeString(url), html.EscapeString(alt)) - if link != "" { + if link = safeLinkURL(link); link != "" { img = fmt.Sprintf(`%s`, html.EscapeString(link), img) } // The positioning wrapper only exists when a chip is rendered, so @@ -424,8 +425,8 @@ func renderBlock(ctx context.Context, block map[string]any) string { name = url } sb.WriteString(`
`) - if url != "" { - fmt.Fprintf(&sb, ``, html.EscapeString(url)) + if href := safeLinkURL(url); href != "" { + fmt.Fprintf(&sb, ``, html.EscapeString(href)) sb.WriteString(html.EscapeString(name)) sb.WriteString("") } else { @@ -627,6 +628,7 @@ func renderInlineContent(content []map[string]any, insideLink bool) string { case "link": href, _ := itemMap["href"].(string) + href = safeLinkURL(href) linkContent := inlineContentFromRaw(itemMap["content"]) if href == "" { sb.WriteString(renderInlineContent(linkContent, insideLink)) @@ -656,6 +658,30 @@ func renderInlineContent(content []map[string]any, insideLink bool) string { return sb.String() } +// safeLinkURL accepts ordinary web, email, telephone, and relative links while +// rejecting active-content schemes such as javascript: and data:. Escaping a +// URL protects the HTML attribute boundary, but it does not make an unsafe URL +// scheme safe to navigate to. +func safeLinkURL(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme == "" { + if err != nil { + return "" + } + return trimmed + } + switch strings.ToLower(parsed.Scheme) { + case "http", "https", "mailto", "tel": + return trimmed + default: + return "" + } +} + // imageChipClass matches the page-builder image block's attribution chip // (backend/blocks/tags via image.ninjatpl in the cms repo) so themes style // blog and page credits identically. @@ -700,8 +726,9 @@ func writeAttributionLink(b *strings.Builder, href, label string) { // Trailing sentence punctuation (.,;:!?'") is excluded from the linked URL. // Closing parens, brackets and braces are kept inside the URL only when // balanced with an opener inside the URL itself — so -// "(see https://example.com)" links only "https://example.com" -// "https://en.wikipedia.org/wiki/Foo_(bar)" keeps the trailing paren. +// +// "(see https://example.com)" links only "https://example.com" +// "https://en.wikipedia.org/wiki/Foo_(bar)" keeps the trailing paren. func autolinkText(text string) string { const scheme = "https://" var sb strings.Builder diff --git a/render/blocknote_autolink_test.go b/render/blocknote_autolink_test.go index 7cf2e77..bfa9199 100644 --- a/render/blocknote_autolink_test.go +++ b/render/blocknote_autolink_test.go @@ -183,6 +183,27 @@ func TestBlockNoteToHTML_NoNestedAnchorInsideExplicitLink(t *testing.T) { } } +func TestBlockNoteToHTML_RejectsActiveContentExplicitLink(t *testing.T) { + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{ + "type": "link", + "href": " javascript:alert(1) ", + "content": []any{map[string]any{"type": "text", "text": "read this"}}, + }, + }, + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + if html != "

read this

\n" { + t.Fatalf("unsafe link should render as plain text: %q", html) + } +} + func TestBlockNoteToHTML_NoAutolinkInsideCodeStyle(t *testing.T) { doc := map[string]any{ "blocks": []any{ diff --git a/render/blocknote_test.go b/render/blocknote_test.go index 688ff49..3321f89 100644 --- a/render/blocknote_test.go +++ b/render/blocknote_test.go @@ -236,6 +236,28 @@ func TestFileBlock(t *testing.T) { } } +func TestFileBlockRejectsActiveContentURL(t *testing.T) { + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "file", + "props": map[string]any{ + "url": "javascript:alert(document.domain)", + "name": "Unsafe link", + }, + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + + if strings.Contains(html, "href=") || strings.Contains(html, "javascript:") { + t.Fatalf("unsafe file URL rendered as a link: %s", html) + } + if !strings.Contains(html, "Unsafe link") { + t.Fatalf("file label should remain visible as plain text: %s", html) + } +} + func TestFileBlockNoName(t *testing.T) { doc := map[string]any{ "blocks": []any{ @@ -708,6 +730,18 @@ func TestImageBlockAltAndLink(t *testing.T) { } } +func TestImageBlockRejectsActiveContentLink(t *testing.T) { + html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{ + "url": "/media/x.webp", "alt": "Sunset", "link": "JaVaScRiPt:alert(1)", + })) + if strings.Contains(html, "