Hello again,

Just recently I was finalizing OAuth Labs for public release. While doing so, I looked for some inspiration on what people usually put in their README for go-specific projects. While browsing various Go projects on GitHub and inspecting their READMEs, I noticed that goreportcard is frequently referenced.

goreportcard is a web application that generates a report on the quality of an open source Go project. It leverages multiple linters to produce a score and integrates with shields.io to provide you with a cool little badge that can be included in your project’s README:

I toyed around with goreportcard for a few minutes and then decided to dig into its source code1. Who knows, maybe I’ll find a vulnerability or two :)

You get an A+

When using goreportcard, users can either request new reports via their landing page /, inspect an existing report under /report/ or queue a new report via requests to /checks.

We’re mainly going to focus on the /checks and /report/ endpoints as they appear to contain the core logic of the web application.

Here’s a quick summary on the relevant endpoints:

  • /checks: The core logic, it processes new go module scans and produces reports.
  • /report/: Loads reports and renders them, uses /checks to trigger new scans.
  • /badge/: Constructs the shields.io image URL and responds with a redirect.

Let’s first check out what happens when everything goes smoothly by tracing the “happy-path”.

The Happy-Path

We start out by opening https://goreportcard.com/report/github.com/gojp/goreportcard in our browser. When navigating to /report/:path the JavaScript logic on this page triggers a request to /checks?repo=<path>. The logic for this request is found under assets/templates/report.html.

256
257
258
259
260
261
262
$(function(){

  if (loading) {
      // we need to load the results
      loadData.call($("form#check_form")[0], true); // <--
  }
}
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
var loadData = function(getRequest){
  loading = true;
  var $form = $(this),
      url = $form.attr("action"),
      method = $form.attr("method"),
      data = {};
    $form.serializeArray().map(function(x){data[x.name] = x.value;});

    if(!data["repo"]) {
        alertMessage("Input cannot be empty. Please enter a valid repository path");
        return false;
    }

    $("#check_form .button").addClass("is-loading");
  $.ajax({
      type: getRequest ? "GET" : "POST",
      url: url,
      data: data,
      dataType: "json"
  }).fail(function(xhr, status, err){
      alertMessage("There was an error processing your request: " + xhr.responseText);
  }).done(function(data, textStatus, jqXHR){
      if (data.redirect) {
          location.replace(data.redirect);
      }
  }).always(function(){
      loading = false;
      $("a.refresh-button").removeClass("is-loading");
      $("#check_form .button").removeClass("is-loading");
      $(".container-loading").slideUp();
  });
  return false;
};

Let’s dig into this request.

The /checks?repo=<path> request is handled by CheckHandler in handlers/check.go:21:

20
21
22
23
24
25
26
27
28
29
30
31
// CheckHandler handles the request for checking a repo
func CheckHandler(w http.ResponseWriter, r *http.Request, db *badger.DB) {
    w.Header().Set("Content-Type", "application/json")

    repo := download.Clean(r.FormValue("repo"))

    c := download.NewProxyClient("https://proxy.golang.org")
    moduleName, err := c.ModuleName(repo)
    if err != nil {
        log.Println("ERROR: could not get module name:", err)
    }
    ...

In the first half of the check handler, we can see that https://proxy.golang.org2 is interacted with. The call to c.ModuleName(repo) is an abstraction responsible for fetching the requested go module from the following URL template:

https://proxy.golang.org/<repo>/@v/<version>.mod

if repo was set to github.com/gojp/goreportcard, this would fetch
https://proxy.golang.org/github.com/gojp/goreportcard/@v/v0.0.0-20241203091639-8d0356773220.mod:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
module github.com/gojp/goreportcard

go 1.22.1

require (
    github.com/alecthomas/gometalinter v1.0.3
    github.com/client9/misspell v0.3.4
    github.com/dgraph-io/badger/v2 v2.2007.2
    github.com/dustin/go-humanize v1.0.1
    github.com/fzipp/gocyclo v0.3.1
    github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8
    github.com/prometheus/client_golang v1.14.0
    golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5
    honnef.co/go/tools v0.1.3

<snip>

The second half of the check handler is responsible for triggering the core functionality of the app; the call to newChecksResp(db, repo, forceRefresh). After returning from this call, the endpoint responds with a redirect to the report to refresh the initially visited page:

36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
    log.Printf("Checking repo %q...", repo)

    forceRefresh := r.Method != "GET" // if this is a GET request, try to fetch from cached version in badger first
    _, err = newChecksResp(db, repo, forceRefresh)
    if err != nil {
        log.Println("ERROR: from newChecksResp:", err)
        http.Error(w, "Could not analyze the repository: "+err.Error(), http.StatusBadRequest)
        return
    }

    b, err := json.Marshal(map[string]string{"redirect": "/report/" + repo})
    if err != nil {
        log.Println("JSON marshal error:", err)
    }
    w.WriteHeader(http.StatusOK)
    w.Write(b)
}

Let’s take a closer look at the core logic by digging into the newChecksResp(db, repo, forceRefresh) call.

New Check, Who Dis?

The first part of newChecksResp, handles already processed reports and serves them from a cache unless forceRefresh is requested:

83
84
85
86
87
88
89
90
91
92
93
94
95
func newChecksResp(db *badger.DB, repo string, forceRefresh bool) (checksResp, error) {
    if !forceRefresh {
        resp, err := getFromCache(db, repo)
        if err != nil {
            // just log the error and continue
            log.Println(err)
        } else {
            resp.Grade = check.GradeFromPercentage(resp.Average * 100) // grade is not stored for some repos, yet
            return resp, nil
        }
    }

    ...

If the report doesn’t exist in the cache or forceRefresh is true, the function continues.

A new ProxyClient is instanciated to interact with proxy.golang.org and invokes c.ProxyDownload(repo):

 95
 96
 97
 98
 99
100
101
102
    c := download.NewProxyClient("https://proxy.golang.org")
    ver, err := c.ProxyDownload(repo)
    if err != nil {
        log.Println("ERROR:", err)
        return checksResp{}, fmt.Errorf("could not download repo: %v", err)
    }

    ...

Let’s dig into the call to c.ProxyDownload(repo) to figure out what’s going on.

First, our repo is passed to c.LatestVersion(path). This call fetches module metadata via proxy.golang.org/<path>/@latest to extract the version number of the module we are requesting a report for.

108
109
110
111
112
113
114
115
// ProxyDownload downloads a package from proxy.golang.org
func (c *ProxyClient) ProxyDownload(path string) (string, error) {
    lowerPath := strings.ToLower(path)

    ver, err := c.LatestVersion(path)
    if err != nil {
        return "", err
    }

Then, the fetched version number is used to construct the zip download URL of the module via c.zipURL(<path>, version). This URL is then queried to fetch the packaged module into a previously constructed zipPath output file.

117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
    resp, err := http.Get(c.zipURL(lowerPath, ver))
    if err != nil {
        return "", err
    }

    defer resp.Body.Close()
    if resp.StatusCode != 200 {
        return "", fmt.Errorf("status %d", resp.StatusCode)
    }

    zipPath := filepath.Join(reposDir, filepath.Base(path)+"@"+ver+".zip")
    out, err := os.Create(zipPath)
    if err != nil {
        return "", err
    }

    defer out.Close()

    _, err = io.Copy(out, resp.Body)
    if err != nil {
        return "", err
    }

Once the module has been downloaded, it is extracted by invoking exec.Command("unzip", ...). The remainder of the function deals with cleanup after the archive has been extracted:

145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
    cmd := exec.Command("unzip", "-o", zipPath, "-d", reposDir)

    err = cmd.Run()
    if err != nil {
        return "", err
    }

    err = os.RemoveAll(zipPath)
    if err != nil {
        return "", err
    }

    // err = os.Rename(filepath.Join(reposDir, lowerPath+"@"+ver), filepath.Join(reposDir, lowerPath))
    // if err != nil {
    //     return "", err
    // }

    return ver, nil
}

Once this function returns, we have successfully downloaded the requested go module to disk.

Returning to where we left off in newChecksResp(), the function carries on by invoking check.Run(string, bool):

102
103
104
105
106
checkResult, err := check.Run(dirName(repo, ver), false)
if err != nil {
    return checksResp{}, err
}
...

check.Run(string, bool) defined in check/check.go is responsible for invoking a set of predefined “checks”:

40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// Run executes all checks on the given directory
func Run(dir string, cli bool) (ChecksResult, error) {
    filenames, skipped, err := GoFiles(dir)
    if err != nil {
        return ChecksResult{}, fmt.Errorf("could not get filenames: %v", err)
    }
    if len(filenames) == 0 {
        return ChecksResult{}, fmt.Errorf("no .go files found")
    }

    err = RenameFiles(skipped)
    if err != nil {
        log.Println("Could not rename files:", err)
    }

    if cli {
        defer RevertFiles(skipped)
    }

    checks := []Check{
        GoFmt{Dir: dir, Filenames: filenames},
        GoVet{Dir: dir, Filenames: filenames},
        // GoLint{Dir: dir, Filenames: filenames},
        GoCyclo{Dir: dir, Filenames: filenames},
        License{Dir: dir, Filenames: []string{}},
        Misspell{Dir: dir, Filenames: filenames},
        IneffAssign{Dir: dir, Filenames: filenames},
        // Staticcheck{Dir: dir, Filenames: filenames},
        // ErrCheck{Dir: dir, Filenames: filenames}, // disable errcheck for now, too slow and not finalized
    }

    ...

Each Check implements the following interface:

 9
10
11
12
13
14
15
16
17
18
// Check describes what methods various checks (gofmt, go lint, etc.)
// should implement
type Check interface {
    Name() string
    Description() string
    Weight() float64
    // Percentage returns the passing percentage of the check,
    // as well as a map of filename to output
    Percentage() (float64, []FileSummary, error)
}

Let’s look at GoVet as an example:

20
21
22
23
// Percentage returns the percentage of .go files that pass go vet
func (g GoVet) Percentage() (float64, []FileSummary, error) {
    return GoTool(g.Dir, g.Filenames, []string{"gometalinter", "--deadline=180s", "--disable-all", "--enable=vet"})
}

GoVet.Percentage() simply invokes GoTool(dir string, filenames, command []string). Inspecting GoTool(dir string, filenames, command []string) we can deduce that this method is responsible for executing a given command against a given directory or series of files:

335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
// GoTool runs a given go command (for example gofmt, go tool vet)
// on a directory
func GoTool(dir string, filenames, command []string) (float64, []FileSummary, error) {
    var enabledCheck = command[0]
    if command[0] == "gometalinter" {
        enabledCheck = command[len(command)-1]
    }

    ...

    cmd := exec.Command(command[0], params...)

    ...

    err = cmd.Wait()
    if exitErr, ok := err.(*exec.ExitError); ok {
        ...
    }

    if len(filenames) == 1 {
        ...
        return float64(lc-errors) / float64(lc), failed, nil
    }

    return float64(len(filenames)-len(failed)) / float64(len(filenames)), failed, nil
}

In essence, here we seem to invoke gometalinter --deadline=180s --disable-all --enable=vet against the previously downloaded go module.

Returning to where we left off in check.Run(), each check is executed in a go routine and their outputs are used in the calculation of the final score (the grade):

 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
    ch := make(chan Score)
    for _, c := range checks {
        go func(c Check) {
            p, summaries, err := c.Percentage()
            errMsg := ""
            if err != nil {
                log.Printf("ERROR: (%s) %v", c.Name(), err)
                errMsg = err.Error()
            }
            s := Score{
                Name:          c.Name(),
                Description:   c.Description(),
                FileSummaries: summaries,
                Weight:        c.Weight(),
                Percentage:    p,
                Error:         errMsg,
            }
            ch <- s
        }(c)
    }

    resp := ChecksResult{
        Files: len(filenames),
    }

    var total, totalWeight float64
    var issues = make(map[string]bool)
    for i := 0; i < len(checks); i++ {
        s := <-ch
        resp.Checks = append(resp.Checks, s)
        total += s.Percentage * s.Weight
        totalWeight += s.Weight
        for _, fs := range s.FileSummaries {
            issues[fs.Filename] = true
        }
        if s.Error != "" {
            resp.DidError = true
        }
    }
    total /= totalWeight

    sort.Sort(ByWeight(resp.Checks))
    resp.Average = total
    resp.Issues = len(issues)
    resp.Grade = GradeFromPercentage(total * 100)

    return resp, nil
}

Once check.Run() returns, the results of the performed checks are cached and delivered back to the user:

102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
    checkResult, err := check.Run(dirName(repo, ver), false)
    if err != nil {
        return checksResp{}, err
    }

    defer func() {
        err := os.RemoveAll(dirName(repo, ver))
        if err != nil {
            log.Println("ERROR: could not remove dir:", err)
        }
    }()

    t := time.Now().UTC()
    resp := checksResp{
        Checks:               checkResult.Checks,
        Average:              checkResult.Average,
        Grade:                checkResult.Grade,
        Files:                checkResult.Files,
        Issues:               checkResult.Issues,
        Repo:                 repo,
        Version:              ver,
        ResolvedRepo:         repo,
        LastRefresh:          t,
        LastRefreshFormatted: t.Format(time.UnixDate),
        LastRefreshHumanized: humanize.Time(t),
        DidError:             checkResult.DidError,
    }

    respBytes, err := json.Marshal(resp)
    if err != nil {
        return checksResp{}, fmt.Errorf("could not marshal json: %v", err)
    }

    // is this a new repo? if so, increase the count in the high scores bucket later
    isNewRepo := false
    var oldRepoBytes []byte
    err = db.View(func(txn *badger.Txn) error {
        item, err := txn.Get([]byte(RepoPrefix + repo))
        if err != nil {
            return err
        }

        err = item.Value(func(val []byte) error {
            oldRepoBytes = val

            return nil
        })

        return err
    })

    if err != nil && err != badger.ErrKeyNotFound {
        log.Println("ERROR getting repo badger:", err)
    }

    isNewRepo = oldRepoBytes == nil

    // if this is a new repo, or the user force-refreshed, update the cache
    if isNewRepo || forceRefresh {
        err = db.Update(func(txn *badger.Txn) error {
            log.Printf("Saving repo %q to cache...", repo)

            // save repo to cache
            err = txn.Set([]byte(RepoPrefix+repo), respBytes)
            if err != nil {
                return err
            }

            return updateMetadata(txn, resp, repo, isNewRepo)
        })

        if err != nil {
            log.Println("Badger writing error:", err)
        }

    }

    err = db.Update(func(txn *badger.Txn) error {
        return updateRecentlyViewed(txn, repo)
    })

    if err != nil {
        log.Printf("ERROR: could not update recently viewed: %v", err)
    }

    return resp, nil
}

Once the report has been generated, the user is redirected to the report page under /report/<repo>, rendering the results:

Recap of the “happy-path”

Alright, I’ve covered the “happy-path”, i.e. what happens when the app works as intended without looking at error branches.

Here’s the tl;dr:

  1. proxy.golang.org is used to lookup the latest version of the module
  2. The latest version is then download and unarchived
  3. The downloaded go module is scanned with multiple linters
  4. The output of the linters are transformed into a report with a final score
  5. The report is delivered back to the requester

Now it’s time to look at the “unhappy-path”, e.g. the fun part.

You get an E[rror]

Let’s take a closer look at what may go wrong along the way of fetching and analyzing new modules; welcome to the “unhappy-path”.

While reading through the “happy-path” I noticed something curious about the way errors are handled when browsing to /report/<path>.

The JavaScript logic for handling the error branch in loadData() has a little surprise for us in store. Here’s the loadData() logic - pay attention to the error handling logic:

323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
var loadData = function(getRequest){
  loading = true;
  var $form = $(this),
      url = $form.attr("action"),
      method = $form.attr("method"),
      data = {};
    $form.serializeArray().map(function(x){data[x.name] = x.value;});

    if(!data["repo"]) {
        alertMessage("Input cannot be empty. Please enter a valid repository path");
        return false;
    }

    $("#check_form .button").addClass("is-loading");

  $.ajax({
      type: getRequest ? "GET" : "POST",
      url: url,
      data: data,
      dataType: "json"
  }).fail(function(xhr, status, err){
      alertMessage("There was an error processing your request: " + xhr.responseText);
  }).done(function(data, textStatus, jqXHR){
    ...
  }).always(function(){
    ...
  });
  return false;
};

The call to alertMessage(msg) is invoked when the $.ajax({}).fail(...) block is executed. Let’s take a closer look at how it behaves:

311
312
313
314
315
316
317
318
319
320
321
function alertMessage(msg){
  var html = templates.alert({message: msg});
  var $alert = $(html);
  $alert.find(".delete").on("click", function(){
      $(this).closest(".notification").remove();
  });
  $("#notifications").children().remove();
  $alert.hide();
  $alert.appendTo("#notifications");
  $alert.slideDown();
}

This function uses the alert handlebars template to construct the required HTML markup and then appends the markup to the DOM via jQuery’s .appendTo(). The alert template is defined in assets/templates/report.html:122-128 and reads:

1
2
3
4
5
6
7
<script id="template-alert" type="text/x-handlebars-template">
    <div class="notification is-warning">
        <button class="delete"></button>
        {{{message}}}
    </div>
  </div>
</script>

This is where things get interesting.

They’re using the tripple-stache {{{ ... }}} placeholder, which disables the HTML sanitizer for this particular part of the template. In other words, the error response of /check?repo=<path> is rendered without sanitization.

Do you see where this is going? :)

Assuming an attacker is able to partially control the error message (the response of /checks?repo=<path>) they might be able to smuggle in arbitrary HTML contents to achieve XSS.

Circling back to the /checks?repo=<path> handler, we can see that err.Error() is delivered back to the user in case the call to newChecksResp() fails:

39
40
41
42
43
44
_, err = newChecksResp(db, repo, forceRefresh)
if err != nil {
    log.Println("ERROR: from newChecksResp:", err)
    http.Error(w, "Could not analyze the repository: "+err.Error(), http.StatusBadRequest)
    return
}

I started looking for branches inside the /checks?repo=<path> endpoint handler that let me tamper with a returned err.Error() message. The error branch in c.LatestVersion(string) looked very promising:

93
94
95
96
if resp.StatusCode != http.StatusOK {
    b, _ := io.ReadAll(resp.Body)
    return "", fmt.Errorf("could not get latest module version from %s: %s", u, string(b))
}

This error branch delivers back the response body of https://proxy.golang.org/<repo>/@latest in case the status code was non-200.

But… I can’t possibly control content served from proxy.golang.org…, right?

proxy.golang.org

I never really bothered looking up what proxy.golang.org exactly is or how it works. All I knew is that it is used to cache (or “mirror”) go modules.

Skimming through the contents of the proxy.golang.org landing page I learned that their service implements the so called GOPROXY protocol. Although I didn’t read this to completion, I paused for a minute on the following section:

Once the go command has found the module root directory, it creates a .zip file of the contents of the directory, then extracts the .zip file into the module cache. See File path and size constraints for details on what files may be included in the .zip file. The contents of the .zip file are authenticated before extraction into the module cache the same way they would be if the .zip file were downloaded from a proxy.

This section pointed me to See File path and size constraints… A list of constraints about the contents of a go module. This part in particular was interesting to me:

File and directory names within a module may consist of Unicode letters, ASCII digits, the ASCII space character (U+0020), and the ASCII punctuation characters !#$%&()+,-.=@[]^_{}~. Note that package paths may not contain all these characters. See module.CheckFilePath and module.CheckImportPath for the differences.

There are restrictions on the filenames in a go module to ensure cross-os compatibility. So, what happens when we try and publish a module with “invalid” filenames?

Fuck around and find out

I quickly set up a dummy github repository containing a funky file named:

tst-<img src='x' onerror='alert("xss")'>.go

Once the repo was in place, I tried fetching https://proxy.golang.org/github.com/cydave/goreportcard_xss_poc/@latest, just like goreportcard does when it does its thing…

Well that’s… pretty neat, our payload is now hosted on proxy.golang.org :)

You get an XSS

With our newly discovered XSS payload hosting provider, all that was left to do was to actually verify the exploit.

Simply navigating to https://goreportcard.com/report/github.com/cydave/goreportcard_xss_poc did the trick:

Just shortly after discovering this vulnerability I contacted Shawn and Herman via email and provided them the steps on how to reproduce. They promptly responded, thanking me for the report and informed me that they’ve addressed the vulnerability :)

’til next time.


  1. Although I have been dabbling in Go for a bit, I still consider myself a n00b. Which is why I enjoy reading other people’s code to see how they do things. Reading through small(ish) code bases makes for a good “code-reading” session. ↩︎

  2. You can think of proxy.golang.org as a repository mirror for go modules - it caches go modules hosted on platforms like GitHub, GitLab and co. ↩︎