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 :)
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”.
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);// <--
}}
varloadData=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");returnfalse;}$("#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();});returnfalse;};
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
funcCheckHandler(whttp.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)iferr!=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:
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:
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)iferr!=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})iferr!=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.
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
funcnewChecksResp(db*badger.DB,repostring,forceRefreshbool)(checksResp,error){if!forceRefresh{resp,err:=getFromCache(db,repo)iferr!=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
returnresp,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)iferr!=nil{log.Println("ERROR:",err)returnchecksResp{},fmt.Errorf("could not download repo: %v",err)}...
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(pathstring)(string,error){lowerPath:=strings.ToLower(path)ver,err:=c.LatestVersion(path)iferr!=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.
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:
// Run executes all checks on the given directory
funcRun(dirstring,clibool)(ChecksResult,error){filenames,skipped,err:=GoFiles(dir)iferr!=nil{returnChecksResult{},fmt.Errorf("could not get filenames: %v",err)}iflen(filenames)==0{returnChecksResult{},fmt.Errorf("no .go files found")}err=RenameFiles(skipped)iferr!=nil{log.Println("Could not rename files:",err)}ifcli{deferRevertFiles(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
}...
// Check describes what methods various checks (gofmt, go lint, etc.)
// should implement
typeCheckinterface{Name()stringDescription()stringWeight()float64// Percentage returns the passing percentage of the check,
// as well as a map of filename to output
Percentage()(float64,[]FileSummary,error)}
// Percentage returns the percentage of .go files that pass go vet
func(gGoVet)Percentage()(float64,[]FileSummary,error){returnGoTool(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:
// GoTool runs a given go command (for example gofmt, go tool vet)
// on a directory
funcGoTool(dirstring,filenames,command[]string)(float64,[]FileSummary,error){varenabledCheck=command[0]ifcommand[0]=="gometalinter"{enabledCheck=command[len(command)-1]}...cmd:=exec.Command(command[0],params...)...err=cmd.Wait()ifexitErr,ok:=err.(*exec.ExitError);ok{...}iflen(filenames)==1{...returnfloat64(lc-errors)/float64(lc),failed,nil}returnfloat64(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):
checkResult,err:=check.Run(dirName(repo,ver),false)iferr!=nil{returnchecksResp{},err}deferfunc(){err:=os.RemoveAll(dirName(repo,ver))iferr!=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)iferr!=nil{returnchecksResp{},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:=falsevaroldRepoBytes[]byteerr=db.View(func(txn*badger.Txn)error{item,err:=txn.Get([]byte(RepoPrefix+repo))iferr!=nil{returnerr}err=item.Value(func(val[]byte)error{oldRepoBytes=valreturnnil})returnerr})iferr!=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
ifisNewRepo||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)iferr!=nil{returnerr}returnupdateMetadata(txn,resp,repo,isNewRepo)})iferr!=nil{log.Println("Badger writing error:",err)}}err=db.Update(func(txn*badger.Txn)error{returnupdateRecentlyViewed(txn,repo)})iferr!=nil{log.Printf("ERROR: could not update recently viewed: %v",err)}returnresp,nil}
Once the report has been generated, the user is redirected to the report page
under /report/<repo>, rendering the results:
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:
varloadData=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");returnfalse;}$("#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(){...});returnfalse;};
The call to alertMessage(msg)
is invoked when the $.ajax({}).fail(...) block is executed. Let’s take a closer look at how it behaves:
This function uses the alerthandlebars 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:
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)iferr!=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
ifresp.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?
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?
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 :)
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.
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. ↩︎
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. ↩︎