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(`
`, 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, "