Commit 480e1cae authored by Nick Thomas's avatar Nick Thomas

Sign LFS upload requests that have been handled by workhorse

This is a necessary change to solve a security issue. For more details,
see this issue: https://gitlab.com/gitlab-org/gitlab-workhorse/issues/197
parent 4e9bf1b2
...@@ -95,7 +95,7 @@ func TestPreAuthorizeRedirect(t *testing.T) { ...@@ -95,7 +95,7 @@ func TestPreAuthorizeRedirect(t *testing.T) {
func TestPreAuthorizeJWT(t *testing.T) { func TestPreAuthorizeJWT(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, err := jwt.Parse(r.Header.Get(api.RequestHeader), func(token *jwt.Token) (interface{}, error) { token, err := jwt.Parse(r.Header.Get(secret.RequestHeader), func(token *jwt.Token) (interface{}, error) {
// Don't forget to validate the alg is what you expect: // Don't forget to validate the alg is what you expect:
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
......
...@@ -23,9 +23,6 @@ const ( ...@@ -23,9 +23,6 @@ const (
// Custom content type for API responses, to catch routing / programming mistakes // Custom content type for API responses, to catch routing / programming mistakes
ResponseContentType = "application/vnd.gitlab-workhorse+json" ResponseContentType = "application/vnd.gitlab-workhorse+json"
// This header carries the JWT token for gitlab-rails
RequestHeader = "Gitlab-Workhorse-Api-Request"
failureResponseLimit = 32768 failureResponseLimit = 32768
) )
...@@ -194,17 +191,6 @@ func (api *API) newRequest(r *http.Request, suffix string) (*http.Request, error ...@@ -194,17 +191,6 @@ func (api *API) newRequest(r *http.Request, suffix string) (*http.Request, error
// This allows the Host header received by the backend to be consistent with other // This allows the Host header received by the backend to be consistent with other
// requests not going through gitlab-workhorse. // requests not going through gitlab-workhorse.
authReq.Host = r.Host authReq.Host = r.Host
// Set a custom header for the request. This can be used in some
// configurations (Passenger) to solve auth request routing problems.
authReq.Header.Set("Gitlab-Workhorse", api.Version)
helper.SetForwardedFor(&authReq.Header, r)
tokenString, err := secret.JWTTokenString(secret.DefaultClaims)
if err != nil {
return nil, fmt.Errorf("newRequest: sign JWT: %v", err)
}
authReq.Header.Set(RequestHeader, tokenString)
return authReq, nil return authReq, nil
} }
...@@ -280,7 +266,9 @@ func (api *API) PreAuthorizeHandler(next HandleFunc, suffix string) http.Handler ...@@ -280,7 +266,9 @@ func (api *API) PreAuthorizeHandler(next HandleFunc, suffix string) http.Handler
} }
func (api *API) doRequestWithoutRedirects(authReq *http.Request) (*http.Response, error) { func (api *API) doRequestWithoutRedirects(authReq *http.Request) (*http.Response, error) {
return api.Client.Transport.RoundTrip(authReq) signingTripper := secret.NewRoundTripper(api.Client.Transport, api.Version)
return signingTripper.RoundTrip(authReq)
} }
func copyAuthHeader(httpResponse *http.Response, w http.ResponseWriter) { func copyAuthHeader(httpResponse *http.Response, w http.ResponseWriter) {
......
package secret
import (
"net/http"
)
const (
// This header carries the JWT token for gitlab-rails
RequestHeader = "Gitlab-Workhorse-Api-Request"
)
type roundTripper struct {
next http.RoundTripper
version string
}
// NewRoundTripper creates a RoundTripper that adds the JWT token header to a
// request. This is used to verify that a request came from workhorse
func NewRoundTripper(next http.RoundTripper, version string) http.RoundTripper {
return &roundTripper{next: next, version: version}
}
func (r *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
tokenString, err := JWTTokenString(DefaultClaims)
if err != nil {
return nil, err
}
// Set a custom header for the request. This can be used in some
// configurations (Passenger) to solve auth request routing problems.
req.Header.Set("Gitlab-Workhorse", r.version)
req.Header.Set(RequestHeader, tokenString)
return r.next.RoundTrip(req)
}
...@@ -2,6 +2,7 @@ package upstream ...@@ -2,6 +2,7 @@ package upstream
import ( import (
"net/http" "net/http"
"net/url"
"path" "path"
"regexp" "regexp"
...@@ -19,6 +20,7 @@ import ( ...@@ -19,6 +20,7 @@ import (
proxypkg "gitlab.com/gitlab-org/gitlab-workhorse/internal/proxy" proxypkg "gitlab.com/gitlab-org/gitlab-workhorse/internal/proxy"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/queueing" "gitlab.com/gitlab-org/gitlab-workhorse/internal/queueing"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/redis" "gitlab.com/gitlab-org/gitlab-workhorse/internal/redis"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/secret"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/senddata" "gitlab.com/gitlab-org/gitlab-workhorse/internal/senddata"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/sendfile" "gitlab.com/gitlab-org/gitlab-workhorse/internal/sendfile"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/sendurl" "gitlab.com/gitlab-org/gitlab-workhorse/internal/sendurl"
...@@ -129,6 +131,21 @@ func (ro *routeEntry) isMatch(cleanedPath string, req *http.Request) bool { ...@@ -129,6 +131,21 @@ func (ro *routeEntry) isMatch(cleanedPath string, req *http.Request) bool {
return ok return ok
} }
func buildProxy(backend *url.URL, version string, rt http.RoundTripper) http.Handler {
proxier := proxypkg.NewProxy(backend, version, rt)
return senddata.SendData(
sendfile.SendFile(apipkg.Block(proxier)),
git.SendArchive,
git.SendBlob,
git.SendDiff,
git.SendPatch,
git.SendSnapshot,
artifacts.SendEntry,
sendurl.SendURL,
)
}
// Routing table // Routing table
// We match against URI not containing the relativeUrlRoot: // We match against URI not containing the relativeUrlRoot:
// see upstream.ServeHTTP // see upstream.ServeHTTP
...@@ -139,23 +156,12 @@ func (u *upstream) configureRoutes() { ...@@ -139,23 +156,12 @@ func (u *upstream) configureRoutes() {
u.Version, u.Version,
u.RoundTripper, u.RoundTripper,
) )
static := &staticpages.Static{DocumentRoot: u.DocumentRoot} static := &staticpages.Static{DocumentRoot: u.DocumentRoot}
proxy := senddata.SendData( proxy := buildProxy(u.Backend, u.Version, u.RoundTripper)
sendfile.SendFile(
apipkg.Block( signingTripper := secret.NewRoundTripper(u.RoundTripper, u.Version)
proxypkg.NewProxy( signingProxy := buildProxy(u.Backend, u.Version, signingTripper)
u.Backend,
u.Version,
u.RoundTripper,
))),
git.SendArchive,
git.SendBlob,
git.SendDiff,
git.SendPatch,
git.SendSnapshot,
artifacts.SendEntry,
sendurl.SendURL,
)
uploadPath := path.Join(u.DocumentRoot, "uploads/tmp") uploadPath := path.Join(u.DocumentRoot, "uploads/tmp")
uploadAccelerateProxy := upload.Accelerate(&upload.SkipRailsAuthorizer{TempPath: uploadPath}, proxy) uploadAccelerateProxy := upload.Accelerate(&upload.SkipRailsAuthorizer{TempPath: uploadPath}, proxy)
...@@ -167,7 +173,7 @@ func (u *upstream) configureRoutes() { ...@@ -167,7 +173,7 @@ func (u *upstream) configureRoutes() {
route("GET", gitProjectPattern+`info/refs\z`, git.GetInfoRefsHandler(api)), route("GET", gitProjectPattern+`info/refs\z`, git.GetInfoRefsHandler(api)),
route("POST", gitProjectPattern+`git-upload-pack\z`, contentEncodingHandler(git.UploadPack(api)), withMatcher(isContentType("application/x-git-upload-pack-request"))), route("POST", gitProjectPattern+`git-upload-pack\z`, contentEncodingHandler(git.UploadPack(api)), withMatcher(isContentType("application/x-git-upload-pack-request"))),
route("POST", gitProjectPattern+`git-receive-pack\z`, contentEncodingHandler(git.ReceivePack(api)), withMatcher(isContentType("application/x-git-receive-pack-request"))), route("POST", gitProjectPattern+`git-receive-pack\z`, contentEncodingHandler(git.ReceivePack(api)), withMatcher(isContentType("application/x-git-receive-pack-request"))),
route("PUT", gitProjectPattern+`gitlab-lfs/objects/([0-9a-f]{64})/([0-9]+)\z`, lfs.PutStore(api, proxy), withMatcher(isContentType("application/octet-stream"))), route("PUT", gitProjectPattern+`gitlab-lfs/objects/([0-9a-f]{64})/([0-9]+)\z`, lfs.PutStore(api, signingProxy), withMatcher(isContentType("application/octet-stream"))),
// CI Artifacts // CI Artifacts
route("POST", apiPattern+`v4/jobs/[0-9]+/artifacts\z`, contentEncodingHandler(artifacts.UploadArtifacts(api, proxy))), route("POST", apiPattern+`v4/jobs/[0-9]+/artifacts\z`, contentEncodingHandler(artifacts.UploadArtifacts(api, proxy))),
......
...@@ -26,6 +26,7 @@ import ( ...@@ -26,6 +26,7 @@ import (
"gitlab.com/gitlab-org/gitlab-workhorse/internal/config" "gitlab.com/gitlab-org/gitlab-workhorse/internal/config"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/gitaly" "gitlab.com/gitlab-org/gitlab-workhorse/internal/gitaly"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/helper" "gitlab.com/gitlab-org/gitlab-workhorse/internal/helper"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/secret"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/testhelper" "gitlab.com/gitlab-org/gitlab-workhorse/internal/testhelper"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/upstream" "gitlab.com/gitlab-org/gitlab-workhorse/internal/upstream"
) )
...@@ -403,7 +404,7 @@ func TestApiContentTypeBlock(t *testing.T) { ...@@ -403,7 +404,7 @@ func TestApiContentTypeBlock(t *testing.T) {
func TestAPIFalsePositivesAreProxied(t *testing.T) { func TestAPIFalsePositivesAreProxied(t *testing.T) {
goodResponse := []byte(`<html></html>`) goodResponse := []byte(`<html></html>`)
ts := testhelper.TestServerWithHandler(regexp.MustCompile(`.`), func(w http.ResponseWriter, r *http.Request) { ts := testhelper.TestServerWithHandler(regexp.MustCompile(`.`), func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(api.RequestHeader) != "" && r.Method != "GET" { if r.Header.Get(secret.RequestHeader) != "" && r.Method != "GET" {
w.WriteHeader(500) w.WriteHeader(500)
w.Write([]byte("non-GET request went through PreAuthorize handler")) w.Write([]byte("non-GET request went through PreAuthorize handler"))
} else { } else {
......
...@@ -4,10 +4,12 @@ import ( ...@@ -4,10 +4,12 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
"io/ioutil"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"regexp" "regexp"
"strconv"
"strings" "strings"
"testing" "testing"
...@@ -84,34 +86,34 @@ func uploadTestServer(t *testing.T, extraTests func(r *http.Request)) *httptest. ...@@ -84,34 +86,34 @@ func uploadTestServer(t *testing.T, extraTests func(r *http.Request)) *httptest.
}) })
} }
func parseJWT(token *jwt.Token) (interface{}, error) {
// Don't forget to validate the alg is what you expect:
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
testhelper.ConfigureSecret()
secretBytes, err := secret.Bytes()
if err != nil {
return nil, fmt.Errorf("read secret from file: %v", err)
}
return secretBytes, nil
}
func TestAcceleratedUpload(t *testing.T) { func TestAcceleratedUpload(t *testing.T) {
reqBody, contentType, err := multipartBodyWithFile() reqBody, contentType, err := multipartBodyWithFile()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
ts := uploadTestServer(t, func(r *http.Request) { ts := uploadTestServer(t, func(r *http.Request) {
jwtToken, err := jwt.Parse(r.Header.Get(upload.RewrittenFieldsHeader), func(token *jwt.Token) (interface{}, error) { jwtToken, err := jwt.Parse(r.Header.Get(upload.RewrittenFieldsHeader), parseJWT)
// Don't forget to validate the alg is what you expect: require.NoError(t, err)
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
testhelper.ConfigureSecret()
secretBytes, err := secret.Bytes()
if err != nil {
return nil, fmt.Errorf("read secret from file: %v", err)
}
return secretBytes, nil
})
if err != nil {
t.Fatal(err)
}
rewrittenFields := jwtToken.Claims.(jwt.MapClaims)["rewritten_fields"].(map[string]interface{}) rewrittenFields := jwtToken.Claims.(jwt.MapClaims)["rewritten_fields"].(map[string]interface{})
if len(rewrittenFields) != 1 || len(rewrittenFields["file"].(string)) == 0 { if len(rewrittenFields) != 1 || len(rewrittenFields["file"].(string)) == 0 {
t.Fatalf("Unexpected rewritten_fields value: %v", rewrittenFields) t.Fatalf("Unexpected rewritten_fields value: %v", rewrittenFields)
} }
}) })
defer ts.Close() defer ts.Close()
...@@ -191,3 +193,69 @@ func TestBlockingRewrittenFieldsHeader(t *testing.T) { ...@@ -191,3 +193,69 @@ func TestBlockingRewrittenFieldsHeader(t *testing.T) {
} }
} }
func TestLfsUpload(t *testing.T) {
reqBody := "test data"
rspBody := "test success"
oid := "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"
resource := fmt.Sprintf("/%s/gitlab-lfs/objects/%s/%d", testRepo, oid, len(reqBody))
lfsApiResponse := fmt.Sprintf(
`{"TempPath":%q, "LfsOid":%q, "LfsSize": %d}`,
scratchDir, oid, len(reqBody),
)
ts := testhelper.TestServerWithHandler(regexp.MustCompile(`.`), func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, r.Method, "PUT")
switch r.RequestURI {
case resource + "/authorize":
// Expect the authorization call to be signed
_, err := jwt.Parse(r.Header.Get(secret.RequestHeader), parseJWT)
require.NoError(t, err)
// Instruct workhorse to accept the upload
w.Header().Set("Content-Type", api.ResponseContentType)
_, err = fmt.Fprint(w, lfsApiResponse)
require.NoError(t, err)
case resource:
// Expect the finalization call to be signed
_, err := jwt.Parse(r.Header.Get(secret.RequestHeader), parseJWT)
require.NoError(t, err)
// Expect the request to point to a file on disk containing the data
require.NoError(t, r.ParseForm())
require.Equal(t, oid, r.Form.Get("file.sha256"), "Invalid SHA256 populated")
require.Equal(t, strconv.Itoa(len(reqBody)), r.Form.Get("file.size"), "Invalid size populated")
tempfile, err := ioutil.ReadFile(r.Form.Get("file.path"))
require.NoError(t, err)
require.Equal(t, reqBody, string(tempfile), "Temporary file has the wrong body")
fmt.Fprint(w, rspBody)
default:
t.Fatalf("Unexpected request to upstream! %v %q", r.Method, r.RequestURI)
}
})
defer ts.Close()
ws := startWorkhorseServer(ts.URL)
defer ws.Close()
req, err := http.NewRequest("PUT", ws.URL+resource, strings.NewReader(reqBody))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = int64(len(reqBody))
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
rspData, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
// Expect the (eventual) response to be proxied through, untouched
require.Equal(t, 200, resp.StatusCode)
require.Equal(t, rspBody, string(rspData))
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment