Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(gateway): include CORS on subdomain redirects #395

Merged
merged 1 commit into from
Jun 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,20 +320,22 @@ func cleanHeaderSet(headers []string) []string {
return result
}

// AddAccessControlHeaders adds default HTTP headers used for controlling
// cross-origin requests. This function adds several values to the
// [Access-Control-Allow-Headers] and [Access-Control-Expose-Headers] entries.
// AddAccessControlHeaders ensures safe default HTTP headers are used for
// controlling cross-origin requests. This function adds several values to the
// [Access-Control-Allow-Headers] and [Access-Control-Expose-Headers] entries
// to be exposed on GET and OPTIONS responses, including [CORS Preflight].
//
// If the Access-Control-Allow-Origin entry is missing a value of '*' is
// If the Access-Control-Allow-Origin entry is missing, a default value of '*' is
// added, indicating that browsers should allow requesting code from any
// origin to access the resource.
//
// If the Access-Control-Allow-Methods entry is missing a value of 'GET' is
// added, indicating that browsers may use the GET method when issuing cross
// If the Access-Control-Allow-Methods entry is missing a value, 'GET, HEAD,
// OPTIONS' is added, indicating that browsers may use them when issuing cross
// origin requests.
//
// [Access-Control-Allow-Headers]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
// [Access-Control-Expose-Headers]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers
// [CORS Preflight]: https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request
func AddAccessControlHeaders(headers map[string][]string) {
hacdias marked this conversation as resolved.
Show resolved Hide resolved
// Hard-coded headers.
const ACAHeadersName = "Access-Control-Allow-Headers"
Expand All @@ -346,8 +348,12 @@ func AddAccessControlHeaders(headers map[string][]string) {
headers[ACAOriginName] = []string{"*"}
}
if _, ok := headers[ACAMethodsName]; !ok {
// Default to GET
headers[ACAMethodsName] = []string{http.MethodGet}
// Default to GET, HEAD, OPTIONS
headers[ACAMethodsName] = []string{
http.MethodGet,
http.MethodHead,
http.MethodOptions,
}
hacdias marked this conversation as resolved.
Show resolved Hide resolved
}

headers[ACAHeadersName] = cleanHeaderSet(
Expand Down
83 changes: 82 additions & 1 deletion gateway/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func TestPretty404(t *testing.T) {
func TestHeaders(t *testing.T) {
t.Parallel()

ts, _, _ := newTestServerAndNode(t, nil, "headers-test.car")
ts, backend, root := newTestServerAndNode(t, nil, "headers-test.car")

var (
rootCID = "bafybeidbcy4u6y55gsemlubd64zk53xoxs73ifd6rieejxcr7xy46mjvky"
Expand Down Expand Up @@ -336,6 +336,87 @@ func TestHeaders(t *testing.T) {
test(dagJsonResponseFormat, dagCborPath, dagCborRoots)
test(dagCborResponseFormat, dagCborPath, dagCborRoots)
})

// Ensures CORS headers are present in HTTP OPTIONS responses
// https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request
t.Run("CORS Preflight Headers", func(t *testing.T) {
// Expect boxo/gateway library's default CORS allowlist for Method
headerACAM := "Access-Control-Allow-Methods"
expectedACAM := []string{http.MethodGet, http.MethodHead, http.MethodOptions}

// Set custom CORS policy to ensure we test user config end-to-end
headerACAO := "Access-Control-Allow-Origin"
expectedACAO := "https://other.example.net"
headers := map[string][]string{}
headers[headerACAO] = []string{expectedACAO}

ts := newTestServerWithConfig(t, backend, Config{
Headers: headers,
PublicGateways: map[string]*PublicGateway{
"subgw.example.com": {
Paths: []string{"/ipfs", "/ipns"},
UseSubdomains: true,
DeserializedResponses: true,
},
},
DeserializedResponses: true,
})
t.Logf("test server url: %s", ts.URL)

testCORSPreflightRequest := func(t *testing.T, path, hostHeader string, requestOriginHeader string, code int) {
req, err := http.NewRequest(http.MethodOptions, ts.URL+path, nil)
assert.Nil(t, err)

if hostHeader != "" {
req.Host = hostHeader
}

if requestOriginHeader != "" {
req.Header.Add("Origin", requestOriginHeader)
}

t.Logf("test req: %+v", req)

// Expect no redirect for OPTIONS request -- https://github.com/ipfs/kubo/issues/9983#issuecomment-1599673976
res := mustDoWithoutRedirect(t, req)
defer res.Body.Close()

t.Logf("test res: %+v", res)

// Expect success
assert.Equal(t, code, res.StatusCode)

// Expect OPTIONS response to have custom CORS header set by user
assert.Equal(t, expectedACAO, res.Header.Get(headerACAO))

// Expect OPTIONS response to have implicit default Allow-Methods
// set by boxo/gateway library
assert.Equal(t, expectedACAM, res.Header[headerACAM])

}

cid := root.String()

t.Run("HTTP OPTIONS response is OK and has defined headers", func(t *testing.T) {
t.Parallel()
testCORSPreflightRequest(t, "/ipfs/"+cid, "", "", http.StatusOK)
})

t.Run("HTTP OPTIONS response for cross-origin /ipfs/cid is OK and has CORS headers", func(t *testing.T) {
t.Parallel()
testCORSPreflightRequest(t, "/ipfs/"+cid, "", "https://other.example.net", http.StatusOK)
})

t.Run("HTTP OPTIONS response for cross-origin /ipfs/cid is HTTP 301 and includes CORS headers (path gw redirect on subdomain gw)", func(t *testing.T) {
t.Parallel()
testCORSPreflightRequest(t, "/ipfs/"+cid, "subgw.example.com", "https://other.example.net", http.StatusMovedPermanently)
})

t.Run("HTTP OPTIONS response for cross-origin is HTTP 200 and has CORS headers (host header on subdomain gw)", func(t *testing.T) {
t.Parallel()
testCORSPreflightRequest(t, "/", cid+".ipfs.subgw.example.com", "https://other.example.net", http.StatusOK)
})
})
}

func TestGoGetSupport(t *testing.T) {
Expand Down
22 changes: 14 additions & 8 deletions gateway/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,19 +157,25 @@ func (i *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}

w.Header().Add("Allow", http.MethodGet)
w.Header().Add("Allow", http.MethodHead)
w.Header().Add("Allow", http.MethodOptions)
addAllowHeader(w)

errmsg := "Method " + r.Method + " not allowed: read only access"
http.Error(w, errmsg, http.StatusMethodNotAllowed)
}

func (i *handler) optionsHandler(w http.ResponseWriter, r *http.Request) {
addAllowHeader(w)
// OPTIONS is a noop request that is used by the browsers to check if server accepts
// cross-site XMLHttpRequest, which is indicated by the presence of CORS headers:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Preflighted_requests
i.addUserHeaders(w) // return all custom headers (including CORS ones, if set)
addCustomHeaders(w, i.config.Headers) // return all custom headers (including CORS ones, if set)
}

// addAllowHeader sets Allow header with supported HTTP methods
func addAllowHeader(w http.ResponseWriter) {
w.Header().Add("Allow", http.MethodGet)
w.Header().Add("Allow", http.MethodHead)
w.Header().Add("Allow", http.MethodOptions)
}

type requestData struct {
Expand Down Expand Up @@ -245,7 +251,7 @@ func (i *handler) getOrHeadHandler(w http.ResponseWriter, r *http.Request) {
trace.SpanFromContext(r.Context()).SetAttributes(attribute.String("ResponseFormat", responseFormat))
i.requestTypeMetric.WithLabelValues(contentPath.Namespace(), responseFormat).Inc()

i.addUserHeaders(w) // ok, _now_ write user's headers.
addCustomHeaders(w, i.config.Headers) // ok, _now_ write user's headers.
w.Header().Set("X-Ipfs-Path", contentPath.String())

// Fail fast if unsupported request type was sent to a Trustless Gateway.
Expand Down Expand Up @@ -321,9 +327,9 @@ func (i *handler) getOrHeadHandler(w http.ResponseWriter, r *http.Request) {
}
}

func (i *handler) addUserHeaders(w http.ResponseWriter) {
for k, v := range i.config.Headers {
w.Header()[k] = v
func addCustomHeaders(w http.ResponseWriter, headers map[string][]string) {
for k, v := range headers {
w.Header()[http.CanonicalHeaderKey(k)] = v
}
}

Expand Down
17 changes: 14 additions & 3 deletions gateway/hostname.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func NewHostnameHandler(c Config, backend IPFSBackend, next http.Handler) http.H
return
}
if newURL != "" {
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
httpRedirectWithHeaders(w, r, newURL, http.StatusMovedPermanently, c.Headers)
return
}
}
Expand Down Expand Up @@ -131,7 +131,7 @@ func NewHostnameHandler(c Config, backend IPFSBackend, next http.Handler) http.H
if newURL != "" {
// Redirect to deterministic CID to ensure CID
// always gets the same Origin on the web
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
httpRedirectWithHeaders(w, r, newURL, http.StatusMovedPermanently, c.Headers)
return
}
}
Expand All @@ -146,7 +146,7 @@ func NewHostnameHandler(c Config, backend IPFSBackend, next http.Handler) http.H
}
if newURL != "" {
// Redirect to CID fixed inside of toSubdomainURL()
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
httpRedirectWithHeaders(w, r, newURL, http.StatusMovedPermanently, c.Headers)
return
}
}
Expand Down Expand Up @@ -559,3 +559,14 @@ func (gws *hostnameGateways) knownSubdomainDetails(hostname string) (gw *PublicG
// no match
return nil, "", "", "", false
}

// httpRedirectWithHeaders applies custom headers before returning a redirect
// response to ensure consistency during transition from path to subdomain
// contexts.
func httpRedirectWithHeaders(w http.ResponseWriter, r *http.Request, url string, code int, headers map[string][]string) {
// ensure things like CORS are applied to redirect responses
// (https://github.com/ipfs/kubo/issues/9983#issuecomment-1599673976)
addCustomHeaders(w, headers)

http.Redirect(w, r, url, code)
}