Download
132 lines 4.9 KiBGoModified 2026-10-20 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
package markdown

import (
	"bytes"
	"html"
	"net/url"
	"path"
	"path/filepath"
	"regexp"
	"strings"
	"testing"

	"code.bruce-hill.com/repo-renderer/internal/highlight"
)

// Run one with `make fuzz`; plain `go test` runs only the seeds.

var fuzzCtx = Context{Dir: "docs/sub", PageURL: pageURL,
	ImageURL: func(p string) string { return "/raw/r/" + escapePath(p) }}

// A README's links come from the pusher. Resolve either leaves one alone
// or points it at a page or file inside the repository, keeping its query
// and fragment.
func FuzzResolve(f *testing.F) {
	for _, s := range []string{
		"guide.md", "./a.md#top", "../../../x", "/../x", "a%2F..%2F..%2F..%2Fx", "%2e%2e/%2e%2e/%2e%2e/x",
		"a?b=1#c", "javascript:alert(1)", "//evil.example/x", "#frag", "", "/", "a\\..\\..\\x", "%zz",
	} {
		f.Add(s, false)
	}
	f.Fuzz(func(t *testing.T, dest string, image bool) {
		out := Resolve(dest, fuzzCtx, image)
		if out == dest {
			return
		}
		prefix := "/code/r"
		if image {
			prefix = "/raw/r"
		}
		u, err := url.Parse(out)
		if err != nil || u.Scheme != "" || u.Host != "" || !strings.HasPrefix(u.Path, prefix) {
			t.Fatalf("Resolve(%q) = %q, not under %s (%v)", dest, out, prefix, err)
		}
		rel := strings.TrimPrefix(strings.TrimPrefix(u.Path, prefix), "/")
		if rel != "" && (path.Clean(rel) != rel || !filepath.IsLocal(rel)) {
			t.Fatalf("Resolve(%q) = %q, which names %q", dest, out, rel)
		}
		if in, err := url.Parse(dest); err == nil && (u.RawQuery != in.RawQuery || u.Fragment != in.Fragment) {
			t.Fatalf("Resolve(%q) = %q, changing the query or fragment", dest, out)
		}
	})
}

// The tags and attributes the renderer itself writes. Anything else in the
// output came from the document and should have been escaped.
var (
	allowedTags = map[string]bool{
		"a": true, "blockquote": true, "br": true, "code": true, "del": true, "div": true, "em": true,
		"h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true, "hr": true, "img": true,
		"input": true, "li": true, "ol": true, "p": true, "pre": true, "span": true, "strong": true,
		"sup": true, "table": true, "tbody": true, "td": true, "th": true, "thead": true, "tr": true, "ul": true,
	}
	allowedAttrs = map[string]bool{
		"alt": true, "checked": true, "class": true, "data-lang": true, "disabled": true, "href": true,
		"id": true, "role": true, "src": true, "start": true, "style": true, "title": true, "type": true,
	}
	tagRE  = regexp.MustCompile(`<(/?)([^\s>/]*)([^>]*)>?`)
	attrRE = regexp.MustCompile(`\s([^\s=>]+)(?:="([^"]*)")?`)
	// What a browser ignores in a URL before deciding its scheme.
	urlNoise = strings.NewReplacer("\t", "", "\n", "", "\r", "")
)

// Whatever a README contains, its page has only markup the renderer wrote:
// no tag, attribute or link scheme that could run script.
func FuzzRender(f *testing.F) {
	hl, err := highlight.New(nil, nil, "github")
	if err != nil {
		f.Fatal(err)
	}
	r := New(hl)
	for _, s := range []string{
		"# Hi\n\n*a* **b** `c` [d](e.md) ![f](g.png)\n", "<script>alert(1)</script>\n", "a <b onclick=x>c</b>\n",
		"[x](javascript:alert(1))\n", "[x](  JaVa\tScRiPt:alert(1))\n", "[x](&#106;avascript:alert(1))\n",
		"<javascript:alert(1)>\n", "![x](data:text/html,<script>)\n", "```html\n<b>\n```\n", "- [ ] a\n- [x] b\n",
		"| a | b |\n|---|:-:|\n| c | d |\n", "x[^1]\n\n[^1]: note\n", "% title\n\nbody\n", "    indented\n",
		"[x]: javascript:alert(1)\n\n[x]\n", "<div>\n*a*\n</div>\n", "~~gone~~ https://example.com\n",
		"```\" onmouseover=\"x\n```\n",
	} {
		f.Add(s)
	}
	f.Fuzz(func(t *testing.T, src string) {
		out, err := r.Render([]byte(src), fuzzCtx)
		if err != nil {
			return
		}
		for _, m := range tagRE.FindAllStringSubmatch(string(out), -1) {
			if !allowedTags[strings.ToLower(m[2])] {
				t.Fatalf("%q renders a <%s%s> tag:\n%s", src, m[1], m[2], out)
			}
			for _, a := range attrRE.FindAllStringSubmatch(m[3], -1) {
				name := strings.ToLower(a[1])
				if !allowedAttrs[name] {
					t.Fatalf("%q renders a %s attribute:\n%s", src, name, out)
				}
				if name != "href" && name != "src" {
					continue
				}
				u := strings.ToLower(strings.TrimLeft(urlNoise.Replace(html.UnescapeString(a[2])), "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f "))
				if strings.HasPrefix(u, "javascript:") || strings.HasPrefix(u, "vbscript:") ||
					strings.HasPrefix(u, "data:") && !strings.HasPrefix(u, "data:image/") {
					t.Fatalf("%q renders %s=%q:\n%s", src, name, a[2], out)
				}
			}
		}
	})
}

// StripPandocMeta only ever drops a prefix.
func FuzzStripPandocMeta(f *testing.F) {
	for _, s := range []string{"", "% t\n% a\n\nbody", "%", "% t\n  more\n\tmore\nbody", "%t", "x\n% t\n"} {
		f.Add([]byte(s))
	}
	f.Fuzz(func(t *testing.T, src []byte) {
		out := StripPandocMeta(src)
		if !bytes.HasSuffix(src, out) {
			t.Fatalf("StripPandocMeta(%q) = %q, not a suffix", src, out)
		}
		if len(src) > 0 && src[0] != '%' && len(out) != len(src) {
			t.Fatalf("StripPandocMeta(%q) = %q without a title block", src, out)
		}
	})
}