fix(render): reject active-content link schemes

This commit is contained in:
Alex Dunmow 2026-08-19 22:44:40 +08:00
parent fb58679b6f
commit abef5b70da
3 changed files with 87 additions and 5 deletions

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"html" "html"
"net/url"
"strings" "strings"
"git.dev.alexdunmow.com/block/pluginsdk/blocks" "git.dev.alexdunmow.com/block/pluginsdk/blocks"
@ -350,7 +351,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
} }
img := fmt.Sprintf(`<img src="%s" alt="%s" />`, html.EscapeString(url), html.EscapeString(alt)) img := fmt.Sprintf(`<img src="%s" alt="%s" />`, html.EscapeString(url), html.EscapeString(alt))
if link != "" { if link = safeLinkURL(link); link != "" {
img = fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(link), img) img = fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(link), img)
} }
// The positioning wrapper only exists when a chip is rendered, so // 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 name = url
} }
sb.WriteString(`<div class="my-4 rounded border border-border p-4">`) sb.WriteString(`<div class="my-4 rounded border border-border p-4">`)
if url != "" { if href := safeLinkURL(url); href != "" {
fmt.Fprintf(&sb, `<a class="text-primary underline" href="%s">`, html.EscapeString(url)) fmt.Fprintf(&sb, `<a class="text-primary underline" href="%s">`, html.EscapeString(href))
sb.WriteString(html.EscapeString(name)) sb.WriteString(html.EscapeString(name))
sb.WriteString("</a>") sb.WriteString("</a>")
} else { } else {
@ -627,6 +628,7 @@ func renderInlineContent(content []map[string]any, insideLink bool) string {
case "link": case "link":
href, _ := itemMap["href"].(string) href, _ := itemMap["href"].(string)
href = safeLinkURL(href)
linkContent := inlineContentFromRaw(itemMap["content"]) linkContent := inlineContentFromRaw(itemMap["content"])
if href == "" { if href == "" {
sb.WriteString(renderInlineContent(linkContent, insideLink)) sb.WriteString(renderInlineContent(linkContent, insideLink))
@ -656,6 +658,30 @@ func renderInlineContent(content []map[string]any, insideLink bool) string {
return sb.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 // imageChipClass matches the page-builder image block's attribution chip
// (backend/blocks/tags via image.ninjatpl in the cms repo) so themes style // (backend/blocks/tags via image.ninjatpl in the cms repo) so themes style
// blog and page credits identically. // 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. // Trailing sentence punctuation (.,;:!?'") is excluded from the linked URL.
// Closing parens, brackets and braces are kept inside the URL only when // Closing parens, brackets and braces are kept inside the URL only when
// balanced with an opener inside the URL itself — so // 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 { func autolinkText(text string) string {
const scheme = "https://" const scheme = "https://"
var sb strings.Builder var sb strings.Builder

View File

@ -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 != "<p class=\"my-4\">read this</p>\n" {
t.Fatalf("unsafe link should render as plain text: %q", html)
}
}
func TestBlockNoteToHTML_NoAutolinkInsideCodeStyle(t *testing.T) { func TestBlockNoteToHTML_NoAutolinkInsideCodeStyle(t *testing.T) {
doc := map[string]any{ doc := map[string]any{
"blocks": []any{ "blocks": []any{

View File

@ -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) { func TestFileBlockNoName(t *testing.T) {
doc := map[string]any{ doc := map[string]any{
"blocks": []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, "<a ") || strings.Contains(strings.ToLower(html), "javascript:") {
t.Fatalf("unsafe image link rendered: %s", html)
}
if !strings.Contains(html, `<img src="/media/x.webp"`) {
t.Fatalf("image should still render without its unsafe link: %s", html)
}
}
func TestImageBlockWidthAndAlign(t *testing.T) { func TestImageBlockWidthAndAlign(t *testing.T) {
html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{ html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
"url": "/media/x.webp", "width": "medium", "align": "right", "url": "/media/x.webp", "width": "medium", "align": "right",