fix(render): reject active-content link schemes
This commit is contained in:
parent
fb58679b6f
commit
abef5b70da
@ -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,6 +726,7 @@ 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"
|
// "(see https://example.com)" links only "https://example.com"
|
||||||
// "https://en.wikipedia.org/wiki/Foo_(bar)" keeps the trailing paren.
|
// "https://en.wikipedia.org/wiki/Foo_(bar)" keeps the trailing paren.
|
||||||
func autolinkText(text string) string {
|
func autolinkText(text string) string {
|
||||||
|
|||||||
@ -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{
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user