1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "bytes"
7 "html/template"
8 "net/url"
9 "path"
10 "regexp"
11 "strconv"
12 "strings"
13
14 "github.com/yuin/goldmark"
15 "github.com/yuin/goldmark/ast"
16 "github.com/yuin/goldmark/extension"
17 "github.com/yuin/goldmark/parser"
18 "github.com/yuin/goldmark/renderer"
19 "github.com/yuin/goldmark/text"
20 "github.com/yuin/goldmark/util"
21)
22
23// headingIDPrefix keeps the ids of README headings apart from the ids of the
24// page (main, keys, cmd-list…): a heading must not be able to take one.
25const headingIDPrefix = "md-"
26
27// markdown renders the Markdown of a repository. Its output is safe to put in
28// a page: raw HTML is dropped, and links and images follow the policy below.
29//
30// Link policy:
31// - http and https links are kept, with rel="nofollow noopener noreferrer";
32// - relative links and links to the root of the repository are rewritten to
33// the blob route of the ref being viewed, never above the repository root;
34// - #fragments point at the (prefixed) heading ids;
35// - everything else (javascript:, data:, vbscript:, file:, mailto:,
36// protocol-relative //host, empty targets…) loses its link: the text stays;
37// - images are never loaded (the CSP forbids other origins, and the design
38// shows an image as its alt text): each becomes <span class="alt">[alt]</span>.
39type markdown struct {
40 md goldmark.Markdown
41}
42
43func newMarkdown() *markdown {
44 return &markdown{md: goldmark.New(
45 goldmark.WithExtensions(extension.GFM),
46 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
47 goldmark.WithRendererOptions(
48 renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100)),
49 // No html.WithUnsafe(): raw HTML blocks and inline tags are dropped.
50 // Table alignment as an align attribute: the CSP forbids style="".
51 extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute),
52 ),
53 )}
54}
55
56// render turns source into HTML and the outline of its headings. repo and ref
57// are where relative links point; dir is the directory of the Markdown file
58// in the repository, "" for the root.
59func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML, []Heading, error) {
60 return m.renderWithIDs(source, repo, ref, dir, headingIDPrefix)
61}
62
63// renderWithIDs is render with the prefix of the heading ids and of the
64// #fragment links chosen, so that several documents on one page (the summaries
65// of a run) do not share ids.
66func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
67 return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, nil)
68}
69
70// renderNote uses the repository Markdown policy, then adds local note links.
71func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
72 h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)
73 return h, err
74}
75
76// renderUntrusted renders Markdown written by an anonymous visitor (an issue
77// approved from the public form). On top of the policy of render, which already
78// drops raw HTML and never loads an image:
79// - only http, https and mailto links are kept, every one with
80// rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
81// every other scheme lose their link and keep the text;
82// - nothing points into the repository.
83func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {
84 h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)
85 return h, err
86}
87
88func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {
89 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
90 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
91 pol.source = source
92 // Collect eligible text before the policy unwraps rejected links: their
93 // labels must not accidentally become references either.
94 var refs []*ast.Text
95 if notes != nil {
96 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
97 if !entering {
98 return ast.WalkContinue, nil
99 }
100 switch n := n.(type) {
101 case *ast.CodeSpan, *ast.Link, *ast.AutoLink, *ast.Image, *ast.CodeBlock, *ast.FencedCodeBlock:
102 return ast.WalkSkipChildren, nil
103 case *ast.Text:
104 refs = append(refs, n)
105 }
106 return ast.WalkContinue, nil
107 })
108 }
109 pol.apply(doc)
110 for _, t := range refs {
111 noteReferences(t, source, notes)
112 }
113
114 var buf bytes.Buffer
115 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
116 return "", nil, err
117 }
118 // #nosec G203 -- goldmark without WithUnsafe escapes text and drops raw
119 // HTML; linkPolicy has removed every URL it does not allow.
120 return template.HTML(buf.String()), outline(doc, source), nil
121}
122
123// prefixedIDs prefixes the heading ids that goldmark generates.
124type prefixedIDs struct {
125 prefix string
126 inner parser.IDs
127}
128
129func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte {
130 return append([]byte(p.prefix), p.inner.Generate(value, kind)...)
131}
132
133func (p *prefixedIDs) Put(value []byte) { p.inner.Put(value) }
134
135// outline lists the headings of a document in order.
136func outline(doc ast.Node, source []byte) []Heading {
137 var out []Heading
138 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
139 h, ok := n.(*ast.Heading)
140 if !ok || !entering {
141 return ast.WalkContinue, nil
142 }
143 id, _ := h.AttributeString("id")
144 idb, _ := id.([]byte)
145 out = append(out, Heading{Level: h.Level, ID: string(idb), Text: plainText(h, source)})
146 return ast.WalkSkipChildren, nil
147 })
148 return out
149}
150
151// plainText is the text of a node without markup.
152func plainText(n ast.Node, source []byte) string {
153 var b strings.Builder
154 _ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) {
155 if !entering {
156 return ast.WalkContinue, nil
157 }
158 switch t := c.(type) {
159 case *ast.Text:
160 b.Write(t.Value(source))
161 if t.SoftLineBreak() || t.HardLineBreak() {
162 b.WriteByte(' ')
163 }
164 case *ast.String:
165 b.Write(t.Value)
166 }
167 return ast.WalkContinue, nil
168 })
169 return strings.TrimSpace(b.String())
170}
171
172// linkPolicy applies the link and image policy to a parsed document.
173type linkPolicy struct {
174 repo, ref, dir string
175 source []byte
176 // idPrefix is the prefix of the heading ids that #fragment links point at.
177 idPrefix string
178 // untrusted is the policy of text from an anonymous visitor.
179 untrusted bool
180}
181
182const (
183 relExternal = "nofollow noopener noreferrer"
184 relUntrusted = "nofollow noopener noreferrer ugc"
185)
186
187func (p linkPolicy) rel() []byte {
188 if p.untrusted {
189 return []byte(relUntrusted)
190 }
191 return []byte(relExternal)
192}
193
194func (p linkPolicy) apply(doc ast.Node) {
195 // Collect first, change after: the walk must not see the tree move.
196 var links, images, autos []ast.Node
197 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
198 if !entering {
199 return ast.WalkContinue, nil
200 }
201 switch n.(type) {
202 case *ast.Link:
203 links = append(links, n)
204 case *ast.Image:
205 images = append(images, n)
206 case *ast.AutoLink:
207 autos = append(autos, n)
208 }
209 return ast.WalkContinue, nil
210 })
211 for _, n := range images {
212 p.image(n.(*ast.Image))
213 }
214 for _, n := range links {
215 l := n.(*ast.Link)
216 dest, kind := p.target(string(l.Destination))
217 if kind == dropped {
218 unwrap(l)
219 continue
220 }
221 l.Destination = []byte(dest)
222 l.Title = nil
223 if kind == external {
224 l.SetAttributeString("rel", p.rel())
225 }
226 }
227 for _, n := range autos {
228 a := n.(*ast.AutoLink)
229 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
230 a.SetAttributeString("rel", p.rel())
231 continue
232 }
233 if p.untrusted && a.AutoLinkType == ast.AutoLinkEmail && validMailbox(string(a.Label(p.source))) {
234 a.SetAttributeString("rel", p.rel())
235 continue
236 }
237 // An email address or another scheme: the label as plain text.
238 a.Parent().ReplaceChild(a.Parent(), a, ast.NewString(append([]byte(nil), a.Label(p.source)...)))
239 }
240}
241
242// unwrap replaces a node by its children.
243func unwrap(n ast.Node) {
244 parent := n.Parent()
245 for c := n.FirstChild(); c != nil; c = n.FirstChild() {
246 parent.InsertBefore(parent, n, c)
247 }
248 parent.RemoveChild(parent, n)
249}
250
251// image replaces an image by its alt text.
252func (p linkPolicy) image(img *ast.Image) {
253 alt := plainText(img, p.source)
254 if alt == "" {
255 alt = "image"
256 }
257 img.Parent().ReplaceChild(img.Parent(), img, &altSpan{alt: alt})
258}
259
260type linkKind int
261
262const (
263 dropped linkKind = iota
264 external
265 internal
266)
267
268// target applies the link policy to a destination.
269func (p linkPolicy) target(dest string) (string, linkKind) {
270 dest = strings.TrimSpace(dest)
271 if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {
272 return "", dropped
273 }
274 if p.untrusted {
275 return untrustedTarget(dest)
276 }
277 if strings.HasPrefix(dest, "#") {
278 frag := strings.ToLower(strings.TrimPrefix(dest, "#"))
279 if frag == "" || strings.ContainsAny(frag, " \"'<>") {
280 return "", dropped
281 }
282 return "#" + p.idPrefix + frag, internal
283 }
284 if strings.HasPrefix(dest, "//") {
285 return "", dropped
286 }
287 u, err := url.Parse(dest)
288 if err != nil {
289 return "", dropped
290 }
291 switch strings.ToLower(u.Scheme) {
292 case "http", "https":
293 if u.Host == "" {
294 return "", dropped
295 }
296 return u.String(), external
297 case "":
298 default:
299 return "", dropped
300 }
301 if u.Host != "" || u.Opaque != "" || u.Path == "" {
302 return "", dropped
303 }
304 // A path in the repository: from the directory of the file, or from the
305 // root when it starts with a slash. path.Clean on a rooted path never
306 // leaves the root, so ../../ cannot climb out of the repository.
307 var rel string
308 if strings.HasPrefix(u.Path, "/") {
309 rel = path.Clean(u.Path)
310 } else {
311 rel = path.Clean("/" + path.Join(p.dir, u.Path))
312 }
313 rel = strings.TrimPrefix(rel, "/")
314 href := repoHref(p.repo)
315 if rel != "" {
316 href = repoHref(p.repo, "blob", p.ref, rel)
317 }
318 if u.Fragment != "" {
319 href += "#" + url.PathEscape(u.Fragment)
320 }
321 return href, internal
322}
323
324// untrustedTarget keeps absolute http and https links with a host, and mailto
325// links to one plain address.
326func untrustedTarget(dest string) (string, linkKind) {
327 if strings.HasPrefix(dest, "//") {
328 return "", dropped
329 }
330 u, err := url.Parse(dest)
331 if err != nil {
332 return "", dropped
333 }
334 switch strings.ToLower(u.Scheme) {
335 case "http", "https":
336 if u.Host == "" || u.User != nil {
337 return "", dropped
338 }
339 return u.String(), external
340 case "mailto":
341 if u.Opaque == "" || u.RawQuery != "" || !validMailbox(u.Opaque) {
342 return "", dropped
343 }
344 return "mailto:" + u.Opaque, external
345 }
346 return "", dropped
347}
348
349// validMailbox tells whether s is a bare address: no space, quote, angle
350// bracket, percent sign, comma or second at sign.
351func validMailbox(s string) bool {
352 local, host, ok := strings.Cut(s, "@")
353 return ok && local != "" && host != "" && len(s) <= 254 &&
354 !strings.ContainsAny(local+host, " \t\r\n\"'<>%,;?#\\@")
355}
356
357func isWebURL(s string) bool {
358 u, err := url.Parse(s)
359 if err != nil || u.Host == "" {
360 return false
361 }
362 sc := strings.ToLower(u.Scheme)
363 return sc == "http" || sc == "https"
364}
365
366// kindAlt is the kind of altSpan.
367var kindAlt = ast.NewNodeKind("AltSpan")
368
369// altSpan stands for an image: its alt text, in a span the stylesheet styles.
370type altSpan struct {
371 ast.BaseInline
372 alt string
373}
374
375func (n *altSpan) Kind() ast.NodeKind { return kindAlt }
376
377func (n *altSpan) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
378
379// altRenderer renders altSpan nodes.
380type altRenderer struct{}
381
382func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
383 r.Register(kindAlt, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
384 if entering {
385 _, _ = w.WriteString(`<span class="alt">[`)
386 _, _ = w.Write(util.EscapeHTML([]byte(n.(*altSpan).alt)))
387 _, _ = w.WriteString(`]</span>`)
388 }
389 return ast.WalkContinue, nil
390 })
391}
392
393var noteReferencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b`)
394
395// noteReferences changes only text segments; code and link labels never enter
396// this function. New fragment links deliberately bypass heading-prefix rewriting.
397func noteReferences(t *ast.Text, source []byte, exists func(int64) bool) {
398 parent := t.Parent()
399 if parent == nil {
400 return
401 }
402 value := t.Value(source)
403 offset := 0
404 for _, match := range noteReferencePattern.FindAllSubmatchIndex(value, -1) {
405 id := string(value[match[4]:match[5]])
406 n, err := strconv.ParseInt(id, 10, 64)
407 if err != nil || !exists(n) {
408 continue
409 }
410 start := match[4] - 1
411 // Markdown punctuation may split adjacent text into separate AST nodes.
412 // Check the original preceding byte as well as the segment-local regex.
413 if at := t.Segment.Start + start; at > 0 {
414 prev := source[at-1]
415 if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' || prev == '^' {
416 continue
417 }
418 }
419 if start > offset {
420 parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
421 }
422 link := ast.NewLink()
423 link.Destination = []byte("#c" + id)
424 link.Title = []byte("note " + id)
425 link.SetAttributeString("class", []byte("ref"))
426 link.AppendChild(link, ast.NewString(value[start:match[5]]))
427 parent.InsertBefore(parent, t, link)
428 offset = match[5]
429 }
430 if offset == 0 {
431 return
432 }
433 tail := ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Stop))
434 tail.SetSoftLineBreak(t.SoftLineBreak())
435 tail.SetHardLineBreak(t.HardLineBreak())
436 parent.InsertBefore(parent, t, tail)
437 parent.RemoveChild(parent, t)
438}