Review fixes

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-01 19:44:23 +04:00
parent e5b155a584
commit 821f66cc20
15 changed files with 255 additions and 39 deletions

View File

@@ -100,7 +100,7 @@ func renderNode(buf *bytes.Buffer, n Node) error {
return fmt.Errorf("cannot render image node: %w", err)
}
buf.WriteString("<img")
writeAttr(buf, "src", attrs.Src)
writeAttr(buf, "src", safeImageSrc(attrs.Src))
if attrs.Alt != nil {
writeAttr(buf, "alt", *attrs.Alt)
}
@@ -274,21 +274,36 @@ const linkRelBlankTargetDefault = "noopener noreferrer"
// linkRelToEmit returns the rel attribute value for a link mark, or empty when
// the attribute should be omitted. When target opens a new browsing context
// (_blank) and the document provides no rel, browsers would grant the opened
// page access to window.opener unless noopener is set.
// (_blank), noopener is always injected to prevent the opened page from
// accessing window.opener, even when the document supplies a custom rel.
func linkRelToEmit(attrs LinkAttrs) string {
blanksTarget := attrs.Target != nil &&
strings.EqualFold(strings.TrimSpace(*attrs.Target), "_blank")
if attrs.Rel != nil {
if s := strings.TrimSpace(*attrs.Rel); s != "" {
if blanksTarget {
return ensureNoopener(s)
}
return s
}
}
if attrs.Target == nil {
return ""
if blanksTarget {
return linkRelBlankTargetDefault
}
if !strings.EqualFold(strings.TrimSpace(*attrs.Target), "_blank") {
return ""
return ""
}
// ensureNoopener returns rel unchanged when it already contains the noopener
// token (case-insensitive check). Otherwise it appends " noopener".
func ensureNoopener(rel string) string {
for tok := range strings.FieldsSeq(rel) {
if strings.EqualFold(tok, "noopener") {
return rel
}
}
return linkRelBlankTargetDefault
return rel + " noopener"
}
func writeAttr(buf *bytes.Buffer, name, value string) {
@@ -334,3 +349,36 @@ func safeLinkHref(href string) string {
}
return href
}
// safeImageSrc returns a value safe to use in an HTML img src attribute.
// Only http, https, and data schemes are permitted; everything else
// (javascript:, vbscript:, etc.) is replaced with an empty string so the
// image simply does not render.
func safeImageSrc(src string) string {
src = strings.TrimSpace(src)
if src == "" {
return ""
}
if strings.HasPrefix(src, "/") {
if len(src) > 1 && (src[1] == '/' || src[1] == '\\') {
return ""
}
return src
}
u, err := url.Parse(src)
if err != nil {
return ""
}
if u.Scheme != "" {
switch strings.ToLower(u.Scheme) {
case "http", "https", "data":
return src
default:
return ""
}
}
if u.Host != "" {
return ""
}
return src
}

View File

@@ -222,6 +222,26 @@ func TestRenderHTML_LinkBlankTargetDefaultRel(t *testing.T) {
raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":" _blank ","rel":" "}}],"text":"hi"}`,
want: `<a href="https://example.com" target=" _blank " rel="noopener noreferrer">hi</a>`,
},
{
name: "custom rel without noopener gets noopener appended",
raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_blank","rel":"nofollow"}}],"text":"hi"}`,
want: `<a href="https://example.com" target="_blank" rel="nofollow noopener">hi</a>`,
},
{
name: "custom rel already has noopener",
raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_blank","rel":"noopener nofollow"}}],"text":"hi"}`,
want: `<a href="https://example.com" target="_blank" rel="noopener nofollow">hi</a>`,
},
{
name: "custom rel with noopener case insensitive",
raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_blank","rel":"NoOpener"}}],"text":"hi"}`,
want: `<a href="https://example.com" target="_blank" rel="NoOpener">hi</a>`,
},
{
name: "custom rel without blank target unchanged",
raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_self","rel":"nofollow"}}],"text":"hi"}`,
want: `<a href="https://example.com" target="_self" rel="nofollow">hi</a>`,
},
} {
t.Run(
tc.name,
@@ -293,6 +313,47 @@ func TestRenderHTML_Image(t *testing.T) {
assert.Equal(t, `<img src="https://example.com/img.png" alt="A photo" title="My image">`, got)
}
func TestRenderHTML_ImageSanitizesDangerousSrc(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
src string
wantSrc string
}{
{name: "javascript scheme", src: `javascript:alert(1)`, wantSrc: ``},
{name: "javascript case insensitive", src: `javaScript:alert(1)`, wantSrc: ``},
{name: "vbscript scheme", src: `vbscript:MsgBox("xss")`, wantSrc: ``},
{name: "protocol-relative", src: `//evil.example/img.png`, wantSrc: ``},
{name: "empty src", src: ``, wantSrc: ``},
{name: "https preserved", src: `https://example.com/img.png`, wantSrc: `https://example.com/img.png`},
{name: "http preserved", src: `http://example.com/img.png`, wantSrc: `http://example.com/img.png`},
{name: "data URI preserved", src: `data:image/png;base64,iVBOR`, wantSrc: `data:image/png;base64,iVBOR`},
{name: "absolute path", src: `/images/photo.png`, wantSrc: `/images/photo.png`},
{name: "relative path", src: `images/photo.png`, wantSrc: `images/photo.png`},
} {
t.Run(
tc.name,
func(t *testing.T) {
t.Parallel()
srcJSON, err := json.Marshal(tc.src)
require.NoError(t, err)
raw := fmt.Sprintf(
`{"type":"image","attrs":{"src":%s}}`,
string(srcJSON),
)
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
got, err := RenderHTML(n)
require.NoError(t, err)
want := fmt.Sprintf(`<img src="%s">`, html.EscapeString(tc.wantSrc))
assert.Equal(t, want, got)
},
)
}
}
func TestRenderHTML_MultipleMarks(t *testing.T) {
t.Parallel()

View File

@@ -330,15 +330,7 @@ func (c *converter) convertImage(n *ast.Image) ([]Node, error) {
imgAttrs.Title = &t
}
// Collect alt text from child text nodes.
var altBuf bytes.Buffer
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
if child.Kind() == ast.KindText {
altBuf.Write(child.(*ast.Text).Segment.Value(c.source))
}
}
if altBuf.Len() > 0 {
alt := altBuf.String()
if alt := c.extractText(n); alt != "" {
imgAttrs.Alt = &alt
}
@@ -524,6 +516,22 @@ func (c *converter) convertStrikethrough(n ast.Node) ([]Node, error) {
return children, nil
}
// extractText recursively collects the text content of all descendant nodes.
func (c *converter) extractText(n ast.Node) string {
var buf bytes.Buffer
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
switch child.Kind() {
case ast.KindText:
buf.Write(child.(*ast.Text).Segment.Value(c.source))
case ast.KindString:
buf.Write(child.(*ast.String).Value)
default:
buf.WriteString(c.extractText(child))
}
}
return buf.String()
}
func copyMarks(marks []Mark) []Mark {
if len(marks) == 0 {
return nil

View File

@@ -255,6 +255,25 @@ func TestParseMarkdown_Image(t *testing.T) {
assert.Equal(t, "title", *attrs.Title)
}
func TestParseMarkdown_ImageFormattedAltText(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("![**bold** and *italic*](https://example.com/img.png)")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
p := doc.Content[0]
require.Len(t, p.Content, 1)
img := p.Content[0]
assert.Equal(t, NodeImage, img.Type)
attrs, err := img.ImageAttrs()
require.NoError(t, err)
require.NotNil(t, attrs.Alt)
assert.Equal(t, "bold and italic", *attrs.Alt)
}
func TestParseMarkdown_BulletList(t *testing.T) {
t.Parallel()

View File

@@ -66,6 +66,9 @@ func SanitizeDocumentJSON(s string) (string, error) {
}
func sanitizeNode(n *Node) {
if n.Type == NodeImage {
sanitizeImageNode(n)
}
for i := range n.Marks {
sanitizeLinkMark(&n.Marks[i])
}
@@ -74,6 +77,23 @@ func sanitizeNode(n *Node) {
}
}
func sanitizeImageNode(n *Node) {
attrs, err := n.ImageAttrs()
if err != nil {
n.Attrs = []byte(`{"src":""}`)
return
}
attrs.Src = safeImageSrc(attrs.Src)
raw, err := json.Marshal(attrs)
if err != nil {
n.Attrs = []byte(`{"src":""}`)
return
}
n.Attrs = raw
}
func sanitizeLinkMark(m *Mark) {
if m.Type != MarkLink {
return

View File

@@ -82,3 +82,37 @@ func TestSanitizeDocumentJSON_PreservesSafeHref(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "https://example.com", attrs.Href)
}
func TestSanitizeDocumentJSON_ImageSrc(t *testing.T) {
t.Parallel()
raw := `{"type":"doc","content":[{"type":"image","attrs":{"src":"javascript:alert(1)","alt":"xss"}}]}`
out, err := SanitizeDocumentJSON(raw)
require.NoError(t, err)
var doc Node
require.NoError(t, json.Unmarshal([]byte(out), &doc))
img := doc.Content[0]
attrs, err := img.ImageAttrs()
require.NoError(t, err)
assert.Equal(t, "", attrs.Src)
require.NotNil(t, attrs.Alt)
assert.Equal(t, "xss", *attrs.Alt)
}
func TestSanitizeDocumentJSON_PreservesSafeImageSrc(t *testing.T) {
t.Parallel()
raw := `{"type":"doc","content":[{"type":"image","attrs":{"src":"https://example.com/img.png","alt":"ok"}}]}`
out, err := SanitizeDocumentJSON(raw)
require.NoError(t, err)
var doc Node
require.NoError(t, json.Unmarshal([]byte(out), &doc))
img := doc.Content[0]
attrs, err := img.ImageAttrs()
require.NoError(t, err)
assert.Equal(t, "https://example.com/img.png", attrs.Src)
}