fix(render): reject active-content link schemes
This commit is contained in:
parent
fb58679b6f
commit
abef5b70da
@ -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(`<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)
|
||||
}
|
||||
// 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(`<div class="my-4 rounded border border-border p-4">`)
|
||||
if url != "" {
|
||||
fmt.Fprintf(&sb, `<a class="text-primary underline" href="%s">`, html.EscapeString(url))
|
||||
if href := safeLinkURL(url); href != "" {
|
||||
fmt.Fprintf(&sb, `<a class="text-primary underline" href="%s">`, html.EscapeString(href))
|
||||
sb.WriteString(html.EscapeString(name))
|
||||
sb.WriteString("</a>")
|
||||
} 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
|
||||
|
||||
@ -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) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
|
||||
@ -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, "<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) {
|
||||
html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
|
||||
"url": "/media/x.webp", "width": "medium", "align": "right",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user