diff --git a/cmd/cloudx/proxy/helpers.go b/cmd/cloudx/proxy/helpers.go index 2a4d1a43..4752ebc8 100644 --- a/cmd/cloudx/proxy/helpers.go +++ b/cmd/cloudx/proxy/helpers.go @@ -143,6 +143,8 @@ func runReverseProxy(ctx context.Context, h *client.CommandHelper, stdErr io.Wri oryURL := client.CloudAPIsURL(project.Slug + ".projects") oryURL.Host = strings.TrimSuffix(oryURL.Host, ":443") + rateLimitName, rateLimitValue, _ := client.RateLimitHeader() + writer := herodot.NewJSONWriter(&errorLogger{Writer: stdErr}) mw := negroni.New() @@ -194,7 +196,7 @@ func runReverseProxy(ctx context.Context, h *client.CommandHelper, stdErr io.Wri PathPrefix: "", }, nil }, - proxy.WithReqMiddleware(reqMiddleware(conf, oryURL, apiKey)), + proxy.WithReqMiddleware(reqMiddleware(conf, oryURL, apiKey, rateLimitName, rateLimitValue)), proxy.WithRespMiddleware(respMiddleware(conf)), )) @@ -272,10 +274,11 @@ and configure your SDKs to point to it, for example in JavaScript: } // reqMiddleware returns the request middleware used by the reverse proxy. The -// Ory-* headers (including the temporary API key in Ory-Base-URL-Rewrite-Token) -// are only attached to Ory-bound requests. Requests forwarded to the developer's -// upstream application do not need — and must not receive — these headers. -func reqMiddleware(conf *config, oryURL *url.URL, apiKey string) proxy.ReqMiddleware { +// Ory-* headers (including the temporary API key in Ory-Base-URL-Rewrite-Token +// and the rate-limit exemption header) are only attached to Ory-bound requests. +// Requests forwarded to the developer's upstream application do not need — and +// must not receive — these headers. +func reqMiddleware(conf *config, oryURL *url.URL, apiKey, rateLimitName, rateLimitValue string) proxy.ReqMiddleware { return func(r *httputil.ProxyRequest, c *proxy.HostConfig, body []byte) ([]byte, error) { // Strip any client-supplied Ory-* headers before selectively re-applying // them below. Otherwise a client could spoof these headers: they would be @@ -300,9 +303,17 @@ func reqMiddleware(conf *config, oryURL *url.URL, apiKey string) proxy.ReqMiddle if len(apiKey) > 0 { r.Out.Header.Set("Ory-Base-URL-Rewrite-Token", apiKey) } - } else if conf.rewriteHost { - r.Out.Header.Set("X-Forwarded-Host", r.In.Host) - r.Out.Host = c.UpstreamHost + if rateLimitValue != "" { + r.Out.Header.Set(rateLimitName, rateLimitValue) + } + } else { + // The rate-limit exemption header is meaningful only to Ory Network + // and must never be forwarded to the developer's upstream application. + r.Out.Header.Del(rateLimitName) + if conf.rewriteHost { + r.Out.Header.Set("X-Forwarded-Host", r.In.Host) + r.Out.Host = c.UpstreamHost + } } return body, nil diff --git a/cmd/cloudx/proxy/helpers_test.go b/cmd/cloudx/proxy/helpers_test.go index db0dfc74..7544b492 100644 --- a/cmd/cloudx/proxy/helpers_test.go +++ b/cmd/cloudx/proxy/helpers_test.go @@ -153,7 +153,7 @@ func TestReqMiddleware(t *testing.T) { conf := &config{publicURL: publicURL} r := newRequest(t, oryURL.Host) - _, err := reqMiddleware(conf, oryURL, apiKey)(r, &proxy.HostConfig{}, nil) + _, err := reqMiddleware(conf, oryURL, apiKey, "", "")(r, &proxy.HostConfig{}, nil) require.NoError(t, err) assert.Equal(t, apiKey, r.Out.Header.Get(headerToken)) @@ -165,7 +165,7 @@ func TestReqMiddleware(t *testing.T) { conf := &config{publicURL: publicURL} r := newRequest(t, "localhost:3000") - _, err := reqMiddleware(conf, oryURL, apiKey)(r, &proxy.HostConfig{}, nil) + _, err := reqMiddleware(conf, oryURL, apiKey, "", "")(r, &proxy.HostConfig{}, nil) require.NoError(t, err) assert.Empty(t, r.Out.Header.Get(headerToken), "API key must not leak to the upstream app") @@ -177,7 +177,7 @@ func TestReqMiddleware(t *testing.T) { conf := &config{publicURL: publicURL, rewriteHost: true} r := newRequest(t, "localhost:3000") - _, err := reqMiddleware(conf, oryURL, apiKey)(r, &proxy.HostConfig{UpstreamHost: "upstream.internal"}, nil) + _, err := reqMiddleware(conf, oryURL, apiKey, "", "")(r, &proxy.HostConfig{UpstreamHost: "upstream.internal"}, nil) require.NoError(t, err) assert.Equal(t, r.In.Host, r.Out.Header.Get("X-Forwarded-Host")) @@ -192,7 +192,7 @@ func TestReqMiddleware(t *testing.T) { r.Out.Header.Set(headerRewrite, "http://evil.example") r.Out.Header.Set(headerNoCustom, "true") - _, err := reqMiddleware(conf, oryURL, apiKey)(r, &proxy.HostConfig{}, nil) + _, err := reqMiddleware(conf, oryURL, apiKey, "", "")(r, &proxy.HostConfig{}, nil) require.NoError(t, err) assert.Empty(t, r.Out.Header.Get(headerToken), "spoofed token must not be forwarded to the upstream app") @@ -205,7 +205,7 @@ func TestReqMiddleware(t *testing.T) { r := newRequest(t, oryURL.Host) r.Out.Header.Set(headerToken, "ory_apikey_spoofed") - _, err := reqMiddleware(conf, oryURL, "")(r, &proxy.HostConfig{}, nil) + _, err := reqMiddleware(conf, oryURL, "", "", "")(r, &proxy.HostConfig{}, nil) require.NoError(t, err) assert.Empty(t, r.Out.Header.Get(headerToken), "spoofed token must not be passed through to Ory when apiKey is empty") @@ -216,9 +216,69 @@ func TestReqMiddleware(t *testing.T) { r := newRequest(t, oryURL.Host) r.Out.Header.Set(headerToken, "ory_apikey_spoofed") - _, err := reqMiddleware(conf, oryURL, apiKey)(r, &proxy.HostConfig{}, nil) + _, err := reqMiddleware(conf, oryURL, apiKey, "", "")(r, &proxy.HostConfig{}, nil) require.NoError(t, err) assert.Equal(t, apiKey, r.Out.Header.Get(headerToken), "genuine key must overwrite any spoofed token") }) + + const ( + headerRateLimit = "Ory-RateLimit-Action" + rateLimitSecret = "rate-limit-secret-sentinel" + ) + + t.Run("case=ory-bound request receives the rate-limit exemption header when configured", func(t *testing.T) { + conf := &config{publicURL: publicURL} + r := newRequest(t, oryURL.Host) + + _, err := reqMiddleware(conf, oryURL, apiKey, headerRateLimit, rateLimitSecret)(r, &proxy.HostConfig{}, nil) + require.NoError(t, err) + + assert.Equal(t, rateLimitSecret, r.Out.Header.Get(headerRateLimit)) + }) + + t.Run("case=upstream-bound request does not receive the rate-limit exemption header", func(t *testing.T) { + conf := &config{publicURL: publicURL} + r := newRequest(t, "localhost:3000") + + _, err := reqMiddleware(conf, oryURL, apiKey, headerRateLimit, rateLimitSecret)(r, &proxy.HostConfig{}, nil) + require.NoError(t, err) + + assert.Empty(t, r.Out.Header.Get(headerRateLimit), "rate-limit secret must not leak to the upstream app") + }) + + t.Run("case=configured rate-limit header overwrites a client-supplied value on Ory-bound requests", func(t *testing.T) { + conf := &config{publicURL: publicURL} + r := newRequest(t, oryURL.Host) + r.Out.Header.Set(headerRateLimit, "client-supplied") + + _, err := reqMiddleware(conf, oryURL, apiKey, headerRateLimit, rateLimitSecret)(r, &proxy.HostConfig{}, nil) + require.NoError(t, err) + + assert.Equal(t, rateLimitSecret, r.Out.Header.Get(headerRateLimit), "configured secret must win over a client-supplied value") + }) + + t.Run("case=client-supplied rate-limit header passes through when none is configured", func(t *testing.T) { + conf := &config{publicURL: publicURL} + r := newRequest(t, oryURL.Host) + r.Out.Header.Set(headerRateLimit, "client-supplied") + + _, err := reqMiddleware(conf, oryURL, apiKey, headerRateLimit, "")(r, &proxy.HostConfig{}, nil) + require.NoError(t, err) + + assert.Equal(t, "client-supplied", r.Out.Header.Get(headerRateLimit), "callers may still send the header themselves") + }) + + t.Run("case=client-supplied rate-limit header is stripped from upstream-bound requests", func(t *testing.T) { + for _, value := range []string{rateLimitSecret, ""} { + conf := &config{publicURL: publicURL} + r := newRequest(t, "localhost:3000") + r.Out.Header.Set(headerRateLimit, "client-supplied") + + _, err := reqMiddleware(conf, oryURL, apiKey, headerRateLimit, value)(r, &proxy.HostConfig{}, nil) + require.NoError(t, err) + + assert.Empty(t, r.Out.Header.Get(headerRateLimit), "the header is meaningful only to Ory Network and must not reach the upstream app (configured value: %q)", value) + } + }) }