Repository: avenga/couper
Pull request #480 (2022-04-12 10:26:16)

Add wildcard pattern option to url attribute
Enables `/**` within `url` attributes

---
<details>
    <summary>Reviewer checklist</summary>
    <ul>
        <li>Read PR description: a summary about the changes is required</li>
        <li>Changelog updated</li>
        <li>Documentation: docs/{Reference, Cli, ...}, Docker and cli help/usage</li>
        <li>Pulled branch, manually tested</li>
        <li>Verified requirements are met</li>
        <li>Reviewed the code</li>
        <li>Reviewed the related tests</li>
    </ul>
</details>



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a5e52f2..fdfe7c93 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ Unreleased changes are available as `avenga/couper:edge` container.
   * `beta_health`-block to `backend`-block to enable continuous health-checks for defined backends ([#313](https://github.com/avenga/couper/pull/313))
     * `backends.<name>.health` variable to access the current health-check state _(subject to change)_
   * Log malformed duration settings ([#487](https://github.com/avenga/couper/pull/487))
+  * `url` attribute could make use of our wildcard pattern `/**` and relative urls in combination with a backend reference ([#480](https://github.com/avenga/couper/pull/480))
   * Error handling for `backend`, `backend_openapi_validation` and `backend_timeout` error types ([#490](https://github.com/avenga/couper/pull/490))
 
 * **Changed**
diff --git a/config/request/context_key.go b/config/request/context_key.go
index 0fa2dcb9..76b58425 100644
--- a/config/request/context_key.go
+++ b/config/request/context_key.go
@@ -35,7 +35,6 @@ const (
 	TokenRequest
 	TokenRequestRetries
 	UID
-	URLAttribute
 	WebsocketsAllowed
 	WebsocketsTimeout
 	Wildcard
diff --git a/config/runtime/endpoint.go b/config/runtime/endpoint.go
index 9739f97a..f43fa2e6 100644
--- a/config/runtime/endpoint.go
+++ b/config/runtime/endpoint.go
@@ -93,6 +93,7 @@ func newEndpointOptions(confCtx *hcl.EvalContext, endpointConf *config.Endpoint,
 		}
 		proxyHandler := handler.NewProxy(backend, proxyConf.HCLBody(), log)
 		p := &producer.Proxy{
+			Content:   proxyConf.HCLBody(),
 			Name:      proxyConf.Name,
 			RoundTrip: proxyHandler,
 		}
@@ -246,6 +247,7 @@ func newSequenceItem(name, previous string,
 	if p, ok := proxies[name]; ok {
 		return producer.Proxies{
 			&producer.Proxy{
+				Content:          p.Content,
 				Name:             p.Name,
 				RoundTrip:        p.RoundTrip,
 				PreviousSequence: previous,
diff --git a/eval/http.go b/eval/http.go
index 82c276ee..61a6664d 100644
--- a/eval/http.go
+++ b/eval/http.go
@@ -112,7 +112,7 @@ func ApplyRequestContext(httpCtx *hcl.EvalContext, body hcl.Body, req *http.Requ
 		return err
 	}
 
-	if err = evalURLPath(req, attrs, httpCtx); err != nil {
+	if err = evalPathAttr(req, attrs, httpCtx); err != nil {
 		return err
 	}
 
@@ -264,34 +264,36 @@ func getFormParams(ctx *hcl.EvalContext, req *http.Request, attrs map[string]*hc
 	return nil
 }
 
-func evalURLPath(req *http.Request, attrs map[string]*hcl.Attribute, httpCtx *hcl.EvalContext) error {
+func evalPathAttr(req *http.Request, attrs map[string]*hcl.Attribute, httpCtx *hcl.EvalContext) error {
+	pathAttr, ok := attrs[attrPath]
+	if !ok {
+		return nil
+	}
 	path := req.URL.Path
-	if pathAttr, ok := attrs[attrPath]; ok {
-		pathValue, err := Value(httpCtx, pathAttr.Expr)
-		if err != nil {
-			return err
+	pathValue, err := Value(httpCtx, pathAttr.Expr)
+	if err != nil {
+		return err
+	}
+	if str := seetie.ValueToString(pathValue); str != "" {
+		// TODO: Check for a valid absolute path
+		if i := strings.Index(str, "#"); i >= 0 {
+			return errors.Configuration.Label("path attribute").Messagef("invalid fragment found in %q", str)
+		} else if i = strings.Index(str, "?"); i >= 0 {
+			return errors.Configuration.Label("path attribute").Messagef("invalid query string found in %q", str)
 		}
-		if str := seetie.ValueToString(pathValue); str != "" {
-			// TODO: Check for a valid absolute path
-			if i := strings.Index(str, "#"); i >= 0 {
-				return errors.Configuration.Label("path attribute").Messagef("invalid fragment found in %q", str)
-			} else if i = strings.Index(str, "?"); i >= 0 {
-				return errors.Configuration.Label("path attribute").Messagef("invalid query string found in %q", str)
-			}
 
-			path = str
-		}
-	}
+		path = str
 
-	if pathMatch, ok := req.Context().
-		Value(request.Wildcard).(string); ok && strings.HasSuffix(path, "/**") {
-		if strings.HasSuffix(req.URL.Path, "/") && !strings.HasSuffix(pathMatch, "/") {
-			pathMatch += "/"
-		}
+		if pathMatch, isWildcard := req.Context().
+			Value(request.Wildcard).(string); isWildcard && strings.HasSuffix(path, "/**") {
+			if strings.HasSuffix(req.URL.Path, "/") && !strings.HasSuffix(pathMatch, "/") {
+				pathMatch += "/"
+			}
 
-		req.URL.Path = utils.JoinPath("/", strings.ReplaceAll(path, "/**", "/"), pathMatch)
-	} else if path != "" {
-		req.URL.Path = utils.JoinPath("/", path)
+			req.URL.Path = utils.JoinPath("/", strings.ReplaceAll(path, "/**", "/"), pathMatch)
+		} else if path != "" {
+			req.URL.Path = utils.JoinPath("/", path)
+		}
 	}
 
 	return nil
diff --git a/eval/value.go b/eval/value.go
index 5773019d..bc308998 100644
--- a/eval/value.go
+++ b/eval/value.go
@@ -20,6 +20,9 @@ var emptyStringVal = cty.StringVal("")
 // ValueFromBodyAttribute lookups the given attribute from given hcl.Body and
 // returns cty.NilVal if the attribute is not present.
 func ValueFromBodyAttribute(ctx *hcl.EvalContext, body hcl.Body, name string) (cty.Value, error) {
+	if body == nil {
+		return cty.NilVal, nil
+	}
 	schema := &hcl.BodySchema{Attributes: []hcl.AttributeSchema{{Name: name}}}
 	content, _, _ := body.PartialContent(schema)
 	if content == nil || len(content.Attributes) == 0 {
diff --git a/handler/producer/proxy.go b/handler/producer/proxy.go
index 5b52b8b9..1d0b066a 100644
--- a/handler/producer/proxy.go
+++ b/handler/producer/proxy.go
@@ -7,13 +7,16 @@ import (
 	"runtime/debug"
 	"sync"
 
+	"github.com/hashicorp/hcl/v2"
 	"go.opentelemetry.io/otel/trace"
 
 	"github.com/avenga/couper/config/request"
+	"github.com/avenga/couper/eval"
 	"github.com/avenga/couper/telemetry"
 )
 
 type Proxy struct {
+	Content          hcl.Body
 	Name             string // label
 	PreviousSequence string
 	RoundTrip        http.RoundTripper
@@ -55,6 +58,21 @@ func (pr Proxies) Produce(clientReq *http.Request, results chan<- *Result) {
 
 		// since proxy and backend may work on the "same" outReq this must be cloned.
 		outReq := clientReq.Clone(outCtx)
+		removeHost(outReq)
+
+		hclCtx := eval.ContextFromRequest(clientReq).HCLContext()
+		url, err := NewURLFromAttribute(hclCtx, proxy.Content, "url", outReq)
+		if err != nil {
+			results <- &Result{Err: err}
+			continue
+		}
+
+		// proxy should pass query if not redefined with url attribute
+		if outReq.URL.RawQuery != "" && url.RawQuery == "" {
+			url.RawQuery = outReq.URL.RawQuery
+		}
+
+		outReq.URL = url
 
 		wg.Add(1)
 		go roundtrip(proxy.RoundTrip, outReq, results, wg)
diff --git a/handler/producer/request.go b/handler/producer/request.go
index 7bf784bb..98c533b5 100644
--- a/handler/producer/request.go
+++ b/handler/producer/request.go
@@ -66,21 +66,21 @@ func (r Requests) Produce(req *http.Request, results chan<- *Result) {
 			continue
 		}
 
-		var method, url string
-
 		methodVal, err := eval.ValueFromAttribute(hclCtx, bodyContent, "method")
 		if err != nil {
 			results <- &Result{Err: err}
 			continue
 		}
-		method = seetie.ValueToString(methodVal)
+		method := seetie.ValueToString(methodVal)
+
+		outreq := req.Clone(req.Context())
+		removeHost(outreq)
 
-		urlVal, err := eval.ValueFromAttribute(hclCtx, bodyContent, "url")
+		url, err := NewURLFromAttribute(hclCtx, or.Context, "url", outreq)
 		if err != nil {
 			results <- &Result{Err: err}
 			continue
 		}
-		url = seetie.ValueToString(urlVal)
 
 		body, defaultContentType, err := eval.GetBody(hclCtx, bodyContent)
 		if err != nil {
@@ -88,10 +88,6 @@ func (r Requests) Produce(req *http.Request, results chan<- *Result) {
 			continue
 		}
 
-		if url != "" {
-			outCtx = context.WithValue(outCtx, request.URLAttribute, url)
-		}
-
 		if method == "" {
 			method = http.MethodGet
 
@@ -100,9 +96,7 @@ func (r Requests) Produce(req *http.Request, results chan<- *Result) {
 			}
 		}
 
-		// The real URL is configured later in the backend,
-		// see <go roundtrip()> at the end of current for-loop.
-		outreq, err := http.NewRequest(strings.ToUpper(method), "", nil)
+		outreq, err = http.NewRequest(strings.ToUpper(method), url.String(), nil)
 		if err != nil {
 			results <- &Result{Err: err}
 			continue
@@ -122,7 +116,7 @@ func (r Requests) Produce(req *http.Request, results chan<- *Result) {
 
 		eval.SetBody(outreq, []byte(body))
 
-		*outreq = *outreq.WithContext(outCtx)
+		outreq = outreq.WithContext(outCtx)
 		err = eval.ApplyRequestContext(hclCtx, or.Context, outreq)
 		if err != nil {
 			results <- &Result{Err: err}
diff --git a/handler/producer/url.go b/handler/producer/url.go
new file mode 100644
index 00000000..ed657499
--- /dev/null
+++ b/handler/producer/url.go
@@ -0,0 +1,67 @@
+package producer
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/hashicorp/hcl/v2"
+	"github.com/zclconf/go-cty/cty"
+
+	"github.com/avenga/couper/config/request"
+	"github.com/avenga/couper/eval"
+	"github.com/avenga/couper/utils"
+)
+
+func NewURLFromAttribute(hclCtx *hcl.EvalContext, content hcl.Body, attrName string, req *http.Request) (*url.URL, error) {
+	urlVal, err := eval.ValueFromBodyAttribute(hclCtx, content, attrName)
+	if err != nil {
+		return nil, err
+	}
+
+	if urlVal.Type() == cty.NilType { // not set
+		return req.URL, nil
+	}
+
+	if urlVal.Type() != cty.String {
+		return nil, fmt.Errorf("url %q: not a string", attrName)
+	}
+
+	urlStr := urlVal.AsString()
+	if urlStr == "" { // no attr
+		return req.URL, nil
+	}
+
+	u, err := url.ParseRequestURI(urlStr) // method forces abs path
+	if err != nil {
+		return nil, err
+	}
+
+	pathMatch, wildcardEP := req.Context().Value(request.Wildcard).(string)
+
+	path := u.Path
+
+	if wildcardEP {
+		if strings.HasSuffix(u.Path, "/**") {
+			if strings.HasSuffix(req.URL.Path, "/") && !strings.HasSuffix(pathMatch, "/") {
+				pathMatch += "/"
+			}
+
+			path = utils.JoinPath("/", strings.ReplaceAll(u.Path, "/**", "/"), pathMatch)
+		}
+
+		if strings.HasSuffix(req.URL.Path, "/") && !strings.HasSuffix(path, "/") {
+			path += "/"
+		}
+	}
+
+	u.Path = path
+	return u, nil
+}
+
+// removeHost prevents client-request host to leak into backend structure.
+func removeHost(outreq *http.Request) {
+	outreq.Host = ""
+	outreq.URL.Host = ""
+}
diff --git a/handler/proxy.go b/handler/proxy.go
index 33198f93..efcaebdb 100644
--- a/handler/proxy.go
+++ b/handler/proxy.go
@@ -71,14 +71,6 @@ func (p *Proxy) RoundTrip(req *http.Request) (*http.Response, error) {
 
 	outCtx := context.WithValue(req.Context(), request.EndpointExpectedStatus, seetie.ValueToIntSlice(expStatusVal))
 
-	urlVal, err := eval.ValueFromBodyAttribute(hclCtx, p.context, "url")
-	if err != nil {
-		return nil, err
-	}
-
-	if url := seetie.ValueToString(urlVal); url != "" {
-		outCtx = context.WithValue(outCtx, request.URLAttribute, url)
-	}
 	*req = *req.WithContext(outCtx)
 
 	if err = p.registerWebsocketsResponse(req); err != nil {
diff --git a/handler/transport/backend.go b/handler/transport/backend.go
index 27434feb..5441eb49 100644
--- a/handler/transport/backend.go
+++ b/handler/transport/backend.go
@@ -296,7 +296,6 @@ func (b *Backend) withTokenRequest(req *http.Request) error {
 	// Reset for upstream transport; prevent mixing values.
 	// tokenRequest will have their own backend configuration.
 	ctx = context.WithValue(ctx, request.BackendParams, nil)
-	ctx = context.WithValue(ctx, request.URLAttribute, "")
 
 	// WithContext() instead of Clone() due to header-map modification.
 	return b.tokenRequest.WithToken(req.WithContext(ctx))
@@ -457,38 +456,18 @@ func (b *Backend) evalTransport(httpCtx *hcl.EvalContext, params hcl.Body, req *
 	} else if strings.HasPrefix(originURL.Host, originURL.Scheme+":") {
 		return nil, errors.Configuration.Label(b.name).
 			Messagef("invalid url: %s", originURL.String())
-	}
-
-	urlAttrValue, _ := req.Context().Value(request.URLAttribute).(string)
-
-	if urlAttrValue != "" {
-		urlAttr, err := url.Parse(urlAttrValue)
-		if err != nil {
-			return nil, errors.Configuration.Label(b.name).With(err)
-		}
-
-		if origin != "" && oidcBackend == "" && urlAttr.Host != originURL.Host {
-			errctx := "url"
-			if tr := req.Context().Value(request.TokenRequest); tr != nil {
-				errctx = "token_endpoint"
-			}
-			return nil, errors.Configuration.Label(b.name).Kind(errctx).
-				Messagef("backend: the host '%s' must be equal to 'backend.origin' host: '%s'",
-					urlAttr.Host, originURL.Host)
-		}
-
-		originURL.Host = urlAttr.Host
-		originURL.Scheme = urlAttr.Scheme
-		req.URL.Scheme = urlAttr.Scheme
-
-		if urlAttr.Path != "" {
-			req.URL.Path = urlAttr.Path
-		}
-
-		if urlAttr.RawQuery != "" {
-			req.URL.RawQuery = urlAttr.RawQuery
-		}
-	}
+	} else if origin == "" {
+		originURL = req.URL
+	} // TODO: still required???
+	//} else if origin != "" && req.URL.Host != originURL.Host {
+	//	errctx := "url"
+	//	if tr := req.Context().Value(request.TokenRequest); tr != nil {
+	//		errctx = "token_endpoint"
+	//	}
+	//	return nil, errors.Configuration.Label(b.name).Kind(errctx).
+	//		Messagef("backend: the host '%s' must be equal to 'backend.origin' host: '%s'",
+	//			req.URL.Host, originURL.Host)
+	//}
 
 	if hostname == "" {
 		hostname = originURL.Host
diff --git a/json/synced_json.go b/json/synced_json.go
index ffcf6766..6f5bd653 100644
--- a/json/synced_json.go
+++ b/json/synced_json.go
@@ -105,9 +105,7 @@ func (s *SyncedJSON) Data() (interface{}, error) {
 func (s *SyncedJSON) fetch() error {
 	req, _ := http.NewRequest("GET", s.uri, nil)
 
-	ctx := context.WithValue(context.Background(), request.URLAttribute, s.uri)
-	ctx = context.WithValue(ctx, request.RoundTripName, s.roundTripName)
-
+	ctx := context.WithValue(context.Background(), request.RoundTripName, s.roundTripName)
 	req = req.WithContext(ctx)
 
 	response, err := s.transport.RoundTrip(req)
diff --git a/main.go b/main.go
index ad4aecff..382d019d 100644
--- a/main.go
+++ b/main.go
@@ -105,7 +105,7 @@ func realmain(arguments []string) int {
 	if cmd == "verify" {
 		log := newLogger(flags.LogFormat, flags.LogLevel, flags.LogPretty)
 
-		err := command.NewCommand(ctx, cmd).Execute(command.Args{flags.FilePath, flags.DirPath}, nil, log)
+		err = command.NewCommand(ctx, cmd).Execute(command.Args{flags.FilePath, flags.DirPath}, nil, log)
 		if err != nil {
 			return 1
 		}
diff --git a/oauth2/client.go b/oauth2/client.go
index bfde6500..b38d6e48 100644
--- a/oauth2/client.go
+++ b/oauth2/client.go
@@ -61,8 +61,12 @@ func (c *Client) newTokenRequest(ctx context.Context, requestParams map[string]s
 		post.Set("client_secret", c.clientConfig.GetClientSecret())
 	}
 
-	// url will be configured via backend roundtrip
-	outreq, err := http.NewRequest(http.MethodPost, "", nil)
+	tokenURL, err := c.asConfig.GetTokenEndpoint()
+	if err != nil {
+		return nil, err
+	}
+
+	outreq, err := http.NewRequest(http.MethodPost, tokenURL, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -77,15 +81,6 @@ func (c *Client) newTokenRequest(ctx context.Context, requestParams map[string]s
 
 	outCtx := context.WithValue(ctx, request.TokenRequest, "oauth2")
 
-	tokenURL, err := c.asConfig.GetTokenEndpoint()
-	if err != nil {
-		return nil, err
-	}
-
-	if tokenURL != "" {
-		outCtx = context.WithValue(outCtx, request.URLAttribute, tokenURL)
-	}
-
 	eval.SetBody(outreq, []byte(post.Encode()))
 
 	return outreq.WithContext(outCtx), nil
diff --git a/oauth2/oidc_client.go b/oauth2/oidc_client.go
index deb8f191..c64674ff 100644
--- a/oauth2/oidc_client.go
+++ b/oauth2/oidc_client.go
@@ -11,7 +11,6 @@ import (
 	"github.com/dgrijalva/jwt-go/v4"
 
 	"github.com/avenga/couper/config"
-	"github.com/avenga/couper/config/request"
 	"github.com/avenga/couper/errors"
 	"github.com/avenga/couper/oauth2/oidc"
 )
@@ -273,15 +272,12 @@ func (o *OidcClient) newUserinfoRequest(ctx context.Context, accessToken string)
 		return nil, err
 	}
 
-	// url will be configured via backend roundtrip
-	outreq, err := http.NewRequest(http.MethodGet, "", nil)
+	outreq, err := http.NewRequest(http.MethodGet, userinfoEndpoint, nil)
 	if err != nil {
 		return nil, err
 	}
 
 	outreq.Header.Set("Authorization", "Bearer "+accessToken)
 
-	outCtx := context.WithValue(ctx, request.URLAttribute, userinfoEndpoint)
-
-	return outreq.WithContext(outCtx), nil
+	return outreq.WithContext(ctx), nil
 }


Test patch:
diff --git a/handler/producer/request_test.go b/handler/producer/request_test.go
index 1d3c3f9f..e76e24d4 100644
--- a/handler/producer/request_test.go
+++ b/handler/producer/request_test.go
@@ -92,6 +92,7 @@ func Test_ProduceExpectedStatus(t *testing.T) {
 		}}
 
 		proxies := producer.Proxies{&producer.Proxy{
+			Content:   hclbody.New(content),
 			Name:      "proxy",
 			RoundTrip: handler.NewProxy(backend, hclbody.New(content), logEntry),
 		}}
diff --git a/internal/test/test_backend.go b/internal/test/test_backend.go
index 2d3e8e18..c04f6e51 100644
--- a/internal/test/test_backend.go
+++ b/internal/test/test_backend.go
@@ -71,6 +71,7 @@ func NewExpiredBackend() (*Backend, *server.SelfSignedCertificate) {
 func registerHTTPHandler(b *Backend) {
 	// test handler
 	b.mux.HandleFunc("/anything", createAnythingHandler(http.StatusOK))
+	b.mux.HandleFunc("/anything/", createAnythingHandler(http.StatusOK))
 	b.mux.HandleFunc("/", createAnythingHandler(http.StatusNotFound))
 	b.mux.HandleFunc("/ws", echo)
 	b.mux.HandleFunc("/redirect", redirect)
diff --git a/server/http_backend_test.go b/server/http_backend_test.go
index fbc29377..1c3ae292 100644
--- a/server/http_backend_test.go
+++ b/server/http_backend_test.go
@@ -10,6 +10,8 @@ import (
 	"testing"
 	"time"
 
+	"github.com/sirupsen/logrus"
+
 	"github.com/avenga/couper/internal/test"
 )
 
@@ -121,3 +123,49 @@ func TestBackend_MaxConnections_BodyClose(t *testing.T) {
 		helper.Must(res.Body.Close())
 	}
 }
+
+// TestBackend_WithoutOrigin expects the listed errors to ensure no host from the client-request
+// leaks into the backend structure for connecting to the origin.
+func TestBackend_WithoutOrigin(t *testing.T) {
+	helper := test.New(t)
+	shutdown, hook := newCouper("testdata/integration/backends/01_couper.hcl", helper)
+	defer shutdown()
+
+	client := test.NewHTTPClient()
+
+	for _, tc := range []struct {
+		path    string
+		message string
+	}{
+		{"/proxy/path", `configuration error: anonymous_6_13: the origin attribute has to contain an absolute URL with a valid hostname: ""`},
+		{"/proxy/backend-path", `configuration error: anonymous_15_13: the origin attribute has to contain an absolute URL with a valid hostname: ""`},
+		{"/proxy/url", `configuration error: anonymous_24_13: the origin attribute has to contain an absolute URL with a valid hostname: ""`},
+		{"/request/backend-path", `configuration error: anonymous_37_15: the origin attribute has to contain an absolute URL with a valid hostname: ""`},
+		{"/request/url", `configuration error: anonymous_46_13: the origin attribute has to contain an absolute URL with a valid hostname: ""`},
+	} {
+		t.Run(tc.path, func(st *testing.T) {
+			hook.Reset()
+
+			h := test.New(st)
+			req, _ := http.NewRequest(http.MethodGet, "http://couper.dev:8080"+tc.path, nil)
+			res, err := client.Do(req)
+			h.Must(err)
+
+			if res.StatusCode != http.StatusInternalServerError {
+				st.Errorf("want: 500, got %d", res.StatusCode)
+			}
+
+			for _, e := range hook.AllEntries() {
+				if e.Level != logrus.ErrorLevel {
+					continue
+				}
+
+				if e.Message != tc.message {
+					st.Errorf("\nwant: %q\ngot:  %q\n", tc.message, e.Message)
+				}
+			}
+
+		})
+
+	}
+}
diff --git a/server/http_endpoints_test.go b/server/http_endpoints_test.go
index c1d153ac..8be6bf24 100644
--- a/server/http_endpoints_test.go
+++ b/server/http_endpoints_test.go
@@ -224,15 +224,6 @@ func TestEndpoints_ProxyReqRes(t *testing.T) {
 	shutdown, logHook := newCouper(filepath.Join(testdataPath, "01_couper.hcl"), helper)
 	defer shutdown()
 
-	defer func() {
-		if !t.Failed() {
-			return
-		}
-		for _, e := range logHook.AllEntries() {
-			println(e.String())
-		}
-	}()
-
 	req, err := http.NewRequest(http.MethodGet, "http://example.com:8080/v1", nil)
 	helper.Must(err)
 
@@ -246,16 +237,16 @@ func TestEndpoints_ProxyReqRes(t *testing.T) {
 		t.Fatalf("Expected 5 log entries, given %d", l)
 	}
 
-	if res.StatusCode != http.StatusNotFound {
-		t.Errorf("Expected status 404, given %d", res.StatusCode)
+	if res.StatusCode != http.StatusOK {
+		t.Errorf("Expected status 200, given %d", res.StatusCode)
 	}
 
 	resBytes, err := io.ReadAll(res.Body)
 	helper.Must(err)
-	res.Body.Close()
+	helper.Must(res.Body.Close())
 
-	if string(resBytes) != "1616" {
-		t.Errorf("Expected body 1616, given %s", resBytes)
+	if string(resBytes) != "800" {
+		t.Errorf("Expected body bytes: 800, given %s", resBytes)
 	}
 }
 
@@ -353,17 +344,6 @@ func TestEndpoints_ProxyReqResCancel(t *testing.T) {
 	shutdown, logHook := newCouper(filepath.Join(testdataPath, "01_couper.hcl"), helper)
 	defer shutdown()
 
-	defer func() {
-		if t.Failed() {
-			for _, e := range logHook.Entries {
-				println(e.String())
-				if d, ok := e.Data["panic"]; ok {
-					println(d)
-				}
-			}
-		}
-	}()
-
 	req, err := http.NewRequest(http.MethodGet, "http://example.com:8080/v1?delay=5s", nil)
 	helper.Must(err)
 
@@ -523,12 +503,12 @@ func TestEndpoints_DoNotExecuteResponseOnErrors(t *testing.T) {
 	helper.Must(err)
 	res.Body.Close()
 
-	if !bytes.Contains(resBytes, []byte("<html>configuration error</html>")) {
-		t.Errorf("Expected body '<html>configuration error</html>', given '%s'", resBytes)
+	if !bytes.Contains(resBytes, []byte("<html>backend error</html>")) {
+		t.Errorf("Expected body '<html>backend error</html>', given '%s'", resBytes)
 	}
 
 	// header from error handling is set
-	if v := res.Header.Get("couper-error"); v != "configuration error" {
+	if v := res.Header.Get("couper-error"); v != "backend error" {
 		t.Errorf("want couper-error 'configuration error', got %q", v)
 	}
 
diff --git a/server/http_integration_test.go b/server/http_integration_test.go
index 354ebe9f..4525bfcf 100644
--- a/server/http_integration_test.go
+++ b/server/http_integration_test.go
@@ -1294,24 +1294,28 @@ func TestHTTPServer_OriginVsURL(t *testing.T) {
 		{"03_couper.hcl", expectation{
 			Path: "/anything",
 			Query: url.Values{
+				"a": []string{"A"},
 				"x": []string{"y"},
 			},
 		}},
 		{"04_couper.hcl", expectation{
 			Path: "/anything",
 			Query: url.Values{
+				"a": []string{"A"},
 				"x": []string{"y"},
 			},
 		}},
 		{"05_couper.hcl", expectation{
 			Path: "/anything",
 			Query: url.Values{
+				"a": []string{"A"},
 				"x": []string{"y"},
 			},
 		}},
 		{"06_couper.hcl", expectation{
 			Path: "/anything",
 			Query: url.Values{
+				"a": []string{"A"},
 				"x": []string{"y"},
 			},
 		}},
@@ -1417,7 +1421,7 @@ func TestHTTPServer_DynamicRequest(t *testing.T) {
 		{expectation{
 			Body:   "body",
 			Method: "PUT",
-			Path:   "/anything",
+			Path:   "/anything/",
 			Query: url.Values{
 				"q": []string{"query"},
 			},
@@ -5378,3 +5382,58 @@ func TestEndpoint_ForLoop(t *testing.T) {
 		})
 	}
 }
+
+func TestWildcardURLAttribute(t *testing.T) {
+	client := newClient()
+
+	shutdown, hook := newCouper("testdata/integration/url/07_couper.hcl", test.New(t))
+	defer shutdown()
+
+	for _, testcase := range []struct{ path, expectedPath, expectedQuery string }{
+		{"/req/anything", "/anything", ""},
+		{"/req/anything/", "/anything/", ""},
+		{"/req-query/anything/?a=b", "/anything/", "a=c"},
+		{"/req-backend/anything/?a=b", "/anything/", "a=c"},
+		{"/proxy/anything", "/anything", ""},
+		{"/proxy/anything/", "/anything/", ""},
+		{"/proxy-query/anything/?a=b", "/anything/", "a=c"},
+		{"/proxy-backend/anything", "/anything", ""},
+		{"/proxy-backend-rel/anything?a=b", "/anything", "a=c"},
+		{"/proxy-backend-path/other-wildcard?a=b", "/anything", "a=c"},
+	} {
+		t.Run(testcase.path[1:], func(st *testing.T) {
+			helper := test.New(st)
+			req, err := http.NewRequest(http.MethodGet, "http://localhost:8080"+testcase.path, nil)
+			helper.Must(err)
+
+			hook.Reset()
+
+			res, err := client.Do(req)
+			helper.Must(err)
+
+			if res.StatusCode != http.StatusOK {
+				st.Error("expected status OK")
+			}
+
+			b, err := io.ReadAll(res.Body)
+			helper.Must(res.Body.Close())
+			helper.Must(err)
+
+			type result struct {
+				Path     string
+				RawQuery string
+			}
+			r := result{}
+			helper.Must(json.Unmarshal(b, &r))
+			//st.Logf("%v", r)
+
+			if testcase.expectedPath != r.Path {
+				st.Errorf("Expected path: %q, got: %q", testcase.expectedPath, r.Path)
+			}
+
+			if testcase.expectedQuery != r.RawQuery {
+				st.Errorf("Expected query: %q, got: %q", testcase.expectedQuery, r.RawQuery)
+			}
+		})
+	}
+}
diff --git a/server/testdata/endpoints/01_couper.hcl b/server/testdata/endpoints/01_couper.hcl
index ed17c299..a9bc6020 100644
--- a/server/testdata/endpoints/01_couper.hcl
+++ b/server/testdata/endpoints/01_couper.hcl
@@ -49,14 +49,14 @@ server "api" {
 definitions {
   # backend origin within a definition block gets replaced with the integration test "anything" server.
   backend "proxy" {
-    path = "/override/me"
+    path = "/anything"
     origin = env.COUPER_TEST_BACKEND_ADDR
     set_request_headers = {
       x-data = "proxy-test"
     }
   }
   backend "request" {
-    path = "/override/me"
+    path = "/anything"
     origin = env.COUPER_TEST_BACKEND_ADDR
     set_request_headers = {
       x-data = "request-test"
diff --git a/server/testdata/endpoints/09_couper.hcl b/server/testdata/endpoints/09_couper.hcl
index 0fa50679..1da4f0e3 100644
--- a/server/testdata/endpoints/09_couper.hcl
+++ b/server/testdata/endpoints/09_couper.hcl
@@ -3,12 +3,10 @@ server "api" {
 
   endpoint "/" {
     proxy {
-      # should fail
-      url = "https://foo.com"
-
+      url = "/?delay=5s"
       backend {
-        origin = "https://bar.com"
-
+        origin = "${env.COUPER_TEST_BACKEND_ADDR}"
+        timeout = "1s"
         # should not run
         set_response_headers = {
           x-backend = 1
diff --git a/server/testdata/integration/backends/01_couper.hcl b/server/testdata/integration/backends/01_couper.hcl
new file mode 100644
index 00000000..e06c21a7
--- /dev/null
+++ b/server/testdata/integration/backends/01_couper.hcl
@@ -0,0 +1,54 @@
+server {
+  api {
+    base_path = "/proxy"
+
+    endpoint "/path" {
+      proxy {
+        path = "/my-path"
+        backend {
+          # no origin
+        }
+      }
+    }
+
+    endpoint "/backend-path" {
+      proxy {
+        backend {
+          path = "/my-path"
+          # no origin
+        }
+      }
+    }
+
+    endpoint "/url" {
+      proxy {
+        url = "/my-path"
+        backend {
+          # no origin
+        }
+      }
+    }
+  }
+
+  api {
+    base_path = "/request"
+
+    endpoint "/backend-path" {
+      request {
+        backend {
+          path = "/my-path"
+          # no origin
+        }
+      }
+    }
+
+    endpoint "/url" {
+      proxy {
+        url = "/my-path"
+        backend {
+          # no origin
+        }
+      }
+    }
+  }
+}
diff --git a/server/testdata/integration/url/07_couper.hcl b/server/testdata/integration/url/07_couper.hcl
new file mode 100644
index 00000000..b70b96ff
--- /dev/null
+++ b/server/testdata/integration/url/07_couper.hcl
@@ -0,0 +1,66 @@
+server {
+  endpoint "/req/**" {
+    request {
+      url = "${env.COUPER_TEST_BACKEND_ADDR}/**"
+    }
+  }
+
+  endpoint "/req-backend/**" {
+    request {
+      url = "/**?a=c"
+      backend = "relative"
+    }
+  }
+
+  endpoint "/req-query/**" {
+    request {
+      url = "${env.COUPER_TEST_BACKEND_ADDR}/**?a=c"
+    }
+  }
+
+  endpoint "/proxy/**" {
+    proxy {
+      url = "${env.COUPER_TEST_BACKEND_ADDR}/**"
+    }
+  }
+
+  endpoint "/proxy-query/**" {
+    proxy {
+      url = "${env.COUPER_TEST_BACKEND_ADDR}/**?a=c"
+    }
+  }
+
+  endpoint "/proxy-backend/**" {
+    proxy {
+      backend {
+        origin = "${env.COUPER_TEST_BACKEND_ADDR}"
+        path = "/**"
+      }
+    }
+  }
+
+  endpoint "/proxy-backend-rel/**" {
+    proxy {
+      url = "/**?a=c"
+      backend = "relative"
+    }
+  }
+
+  endpoint "/proxy-backend-path/**" {
+    proxy {
+      url = "/**?a=c"
+      backend = "relative_path_wins"
+    }
+  }
+}
+
+definitions {
+  backend "relative" {
+    origin = "${env.COUPER_TEST_BACKEND_ADDR}"
+  }
+
+  backend "relative_path_wins" {
+    origin = "${env.COUPER_TEST_BACKEND_ADDR}"
+    path = "/anything"
+  }
+}