[ 
https://issues.apache.org/jira/browse/GROOVY-12182?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18097789#comment-18097789
 ] 

ASF GitHub Bot commented on GROOVY-12182:
-----------------------------------------

Copilot commented on code in PR #2726:
URL: https://github.com/apache/groovy/pull/2726#discussion_r3620831463


##########
subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy:
##########
@@ -381,9 +501,62 @@ final class HttpBuilder {
             }
             target = baseUri.resolve(target.toString())
         }
+        enforceConfinement(target)
         return appendQuery(target, query)
     }
 
+    /**
+     * When {@code confineToBaseUri} is enabled, rejects any request whose
+     * resolved URI leaves the {@link Config#baseUri} namespace: a different
+     * origin (scheme or authority), or a path that escapes the base path
+     * prefix (e.g. an absolute {@code /other} path or {@code ..} traversal).
+     * <p>
+     * The permitted set is exactly the URIs reachable by resolving a
+     * non-escaping relative reference against {@code baseUri}. Comparison uses
+     * raw (percent-encoded) path segments; a server that decodes {@code 
%2e%2e}
+     * itself is outside the scope of this check.
+     *
+     * @param target the fully resolved request URI
+     * @throws SecurityException if the target escapes the configured base URI
+     */
+    private void enforceConfinement(final URI target) {
+        if (!confineToBaseUri || baseUri == null) {
+            return
+        }
+        URI normalized = target.normalize()
+        boolean sameScheme = 
baseUri.scheme?.equalsIgnoreCase(normalized.scheme)
+        boolean sameAuthority = baseUri.authority != null &&
+                baseUri.authority.equalsIgnoreCase(normalized.authority)
+        if (!sameScheme || !sameAuthority || !isPathWithin(baseUri, 
normalized)) {
+            throw new SecurityException(
+                    "Request URI '" + target + "' is not confined to baseUri 
'" + baseUri + "'")
+        }

Review Comment:
   `URI.getAuthority()` string equality is too strict for “same origin” checks: 
it can differ while still being the same origin (e.g., explicit default ports 
like `:80`/`:443`, or presence/absence of user-info). This will incorrectly 
reject otherwise-confined redirects/requests; compare normalized `host` 
(case-insensitive) and an “effective port” (explicit port if present, otherwise 
default for the scheme) instead of `authority` string equality.



##########
subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy:
##########
@@ -317,60 +339,158 @@ final class HttpBuilder {
     }
 
     private HttpRequest buildStreamRequest(final String method, final Object 
uri, final Closure<?> spec) {
+        // Note: streaming does not auto-follow redirects under confinement. 
Because
+        // the body is an unbuffered publisher, a 3xx is returned to the 
caller as-is
+        // rather than followed. This is safe (no bypass) but not transparent; 
callers
+        // who need confined streaming redirects should resolve the Location 
themselves.
+        RequestSpec requestSpec = evalSpec(spec)
+        URI resolvedUri = resolveUri(uri, requestSpec.queryParameters)
+        return buildHopRequest(method, resolvedUri, 
combinedHeaders(requestSpec), requestSpec.body, requestSpec.timeout)
+    }
+
+    private RequestSpec evalSpec(final Closure<?> spec) {
         RequestSpec requestSpec = new RequestSpec()
         if (spec != null) {
             Closure<?> code = (Closure<?>) spec.clone()
             code.resolveStrategy = Closure.DELEGATE_FIRST
             code.delegate = requestSpec
             code.call()
         }
+        return requestSpec
+    }
 
-        URI resolvedUri = resolveUri(uri, requestSpec.queryParameters)
-        HttpRequest.Builder requestBuilder = 
HttpRequest.newBuilder(resolvedUri)
+    private Map<String, String> combinedHeaders(final RequestSpec requestSpec) 
{
+        Map<String, String> headers = new LinkedHashMap<>(defaultHeaders)
+        headers.putAll(requestSpec.headers)
+        return headers
+    }
 
-        Duration timeout = requestSpec.timeout ?: defaultRequestTimeout
+    private HttpRequest buildHopRequest(final String method, final URI uri,
+                                        final Map<String, String> headers,
+                                        final Object body, final Duration 
requestTimeout) {
+        HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(uri)
+        Duration timeout = requestTimeout ?: defaultRequestTimeout
         if (timeout != null) {
             requestBuilder.timeout(timeout)
         }
-
-        defaultHeaders.each { String name, String value ->
-            requestBuilder.header(name, value)
-        }
-        requestSpec.headers.each { String name, String value ->
+        headers.each { String name, String value ->
             requestBuilder.setHeader(name, value)
         }
-
-        requestBuilder.method(method, bodyPublisher(method, requestSpec.body))
+        requestBuilder.method(method, bodyPublisher(method, body))
         return requestBuilder.build()
     }
 
-    private List buildRequest(final String method, final Object uri, final 
Closure<?> spec) {
-        RequestSpec requestSpec = new RequestSpec()
-        if (spec != null) {
-            Closure<?> code = (Closure<?>) spec.clone()
-            code.resolveStrategy = Closure.DELEGATE_FIRST
-            code.delegate = requestSpec
-            code.call()
+    private <T> HttpResponse<T> send(final String method, final HttpRequest 
httpRequest,
+                                     final HttpResponse.BodyHandler<T> 
bodyHandler) {
+        try {
+            return client.send(httpRequest, bodyHandler)
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt()
+            throw new RuntimeException("HTTP request " + method + " " + 
httpRequest.uri() + " was interrupted", e)
+        } catch (IOException e) {
+            throw new RuntimeException("I/O error during HTTP request " + 
method + " " + httpRequest.uri(), e)
         }
+    }
 
-        URI resolvedUri = resolveUri(uri, requestSpec.queryParameters)
-        HttpRequest.Builder requestBuilder = 
HttpRequest.newBuilder(resolvedUri)
+    /**
+     * Synchronously follows redirects while confinement is active, applying
+     * {@link #enforceConfinement} to every hop. Because a confined hop must 
share
+     * the base URI's origin, a redirect to another host is rejected outright 
— so
+     * sensitive headers can never leak across origins here.
+     */
+    private HttpResponse<String> followRedirects(String method, URI 
currentUri, HttpResponse<String> response,
+                                                 Map<String, String> headers, 
Object body,
+                                                 Duration timeout, 
HttpResponse.BodyHandler<String> bodyHandler) {
+        int redirects = 0
+        while (true) {
+            URI target = redirectTarget(currentUri, response)
+            if (target == null) {
+                return response
+            }
+            if (++redirects > MAX_REDIRECTS) {
+                throw new RuntimeException("Too many redirects (> " + 
MAX_REDIRECTS + ") for request confined to " + baseUri)
+            }
+            String nextMethod = redirectMethod(method, response.statusCode())
+            boolean sameMethod = nextMethod == method
+            Object nextBody = sameMethod ? body : null
+            Map<String, String> nextHeaders = sameMethod ? headers : 
withoutBodyHeaders(headers)
+            HttpRequest httpRequest = buildHopRequest(nextMethod, target, 
nextHeaders, nextBody, timeout)
+            response = send(nextMethod, httpRequest, bodyHandler)
+            currentUri = target
+            method = nextMethod
+            body = nextBody
+            headers = nextHeaders
+        }
+    }
 
-        Duration timeout = requestSpec.timeout ?: defaultRequestTimeout
-        if (timeout != null) {
-            requestBuilder.timeout(timeout)
+    /**
+     * Asynchronous counterpart of {@link #followRedirects}. A confinement
+     * violation on a hop surfaces as a failed future carrying the
+     * {@link SecurityException} thrown by {@link #enforceConfinement}.
+     */
+    private CompletableFuture<HttpResponse<String>> followRedirectsAsync(
+            String method, URI currentUri, HttpResponse<String> response,
+            Map<String, String> headers, Object body, Duration timeout,
+            HttpResponse.BodyHandler<String> bodyHandler, int redirects) {
+        URI target = redirectTarget(currentUri, response)
+        if (target == null) {
+            return CompletableFuture.completedFuture(response)
+        }
+        if (redirects + 1 > MAX_REDIRECTS) {
+            CompletableFuture<HttpResponse<String>> failed = new 
CompletableFuture<>()
+            failed.completeExceptionally(
+                    new RuntimeException("Too many redirects (> " + 
MAX_REDIRECTS + ") for request confined to " + baseUri))
+            return failed
+        }
+        String nextMethod = redirectMethod(method, response.statusCode())
+        boolean sameMethod = nextMethod == method
+        Object nextBody = sameMethod ? body : null
+        Map<String, String> nextHeaders = sameMethod ? headers : 
withoutBodyHeaders(headers)
+        HttpRequest httpRequest = buildHopRequest(nextMethod, target, 
nextHeaders, nextBody, timeout)
+        return client.sendAsync(httpRequest, bodyHandler).thenCompose { 
HttpResponse<String> next ->
+            followRedirectsAsync(nextMethod, target, next, nextHeaders, 
nextBody, timeout, bodyHandler, redirects + 1)
         }
+    }
 
-        defaultHeaders.each { String name, String value ->
-            requestBuilder.header(name, value)
+    /**
+     * Returns the confinement-checked redirect target for a response, or
+     * {@code null} if the response is not a followable redirect (non-3xx, or a
+     * 3xx with no {@code Location}). Throws {@link SecurityException} via
+     * {@link #enforceConfinement} if the target escapes the base URI.
+     */
+    private URI redirectTarget(final URI currentUri, final HttpResponse<?> 
response) {
+        int status = response.statusCode()
+        if (status != 301 && status != 302 && status != 303 && status != 307 
&& status != 308) {
+            return null
+        }
+        String location = 
response.headers().firstValue('Location').orElse(null)
+        if (location == null || location.isEmpty()) {
+            return null
+        }
+        URI target = currentUri.resolve(location).normalize()
+        enforceConfinement(target)
+        return target
+    }
+
+    private static String redirectMethod(final String method, final int 
statusCode) {
+        if (statusCode == 303) {
+            return 'GET'
         }
-        requestSpec.headers.each { String name, String value ->
-            requestBuilder.setHeader(name, value)
+        if ((statusCode == 301 || statusCode == 302) &&
+                !('GET'.equalsIgnoreCase(method) || 
'HEAD'.equalsIgnoreCase(method))) {
+            return 'GET'
         }
+        return method // 307/308 preserve method and body
+    }

Review Comment:
   When `followRedirectsManually` is enabled (confined + redirects), redirect 
semantics diverge from the previous behavior that delegated to 
`HttpClient.Redirect.NORMAL`. In particular, this code converts non-GET/HEAD 
methods on 301/302 into GET and proceeds to follow, which `Redirect.NORMAL` 
does not do. To avoid surprising behavior changes triggered solely by enabling 
confinement, align manual redirect-following rules with the JDK client’s 
redirect policy (or explicitly document this behavior change and consider a 
separate option controlling the policy).



##########
subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy:
##########
@@ -381,9 +501,62 @@ final class HttpBuilder {
             }
             target = baseUri.resolve(target.toString())
         }
+        enforceConfinement(target)
         return appendQuery(target, query)
     }
 
+    /**
+     * When {@code confineToBaseUri} is enabled, rejects any request whose
+     * resolved URI leaves the {@link Config#baseUri} namespace: a different
+     * origin (scheme or authority), or a path that escapes the base path
+     * prefix (e.g. an absolute {@code /other} path or {@code ..} traversal).
+     * <p>
+     * The permitted set is exactly the URIs reachable by resolving a
+     * non-escaping relative reference against {@code baseUri}. Comparison uses
+     * raw (percent-encoded) path segments; a server that decodes {@code 
%2e%2e}
+     * itself is outside the scope of this check.
+     *
+     * @param target the fully resolved request URI
+     * @throws SecurityException if the target escapes the configured base URI
+     */
+    private void enforceConfinement(final URI target) {
+        if (!confineToBaseUri || baseUri == null) {
+            return
+        }
+        URI normalized = target.normalize()
+        boolean sameScheme = 
baseUri.scheme?.equalsIgnoreCase(normalized.scheme)
+        boolean sameAuthority = baseUri.authority != null &&
+                baseUri.authority.equalsIgnoreCase(normalized.authority)
+        if (!sameScheme || !sameAuthority || !isPathWithin(baseUri, 
normalized)) {

Review Comment:
   The newly introduced origin-matching logic (scheme/authority and port 
handling) and redirect-following semantics aren’t fully covered by tests. Add 
targeted tests for (1) redirects/requests that differ only by explicit default 
port (e.g., `http://host/` → `http://host:80/...`) and (2) confined redirect 
behavior for non-GET methods on 301/302 to ensure the intended policy is 
enforced and stable.



##########
subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java:
##########
@@ -61,4 +61,13 @@
 
     /** Whether to follow HTTP redirects. Default is false. */
     boolean followRedirects() default false;
+
+    /**
+     * Whether requests are confined to the base URL. When {@code true}, a
+     * request whose resolved URI leaves the base URL's origin or path prefix
+     * (for example via an absolute path, {@code ..} traversal, or a {@code 
create}
+     * override to another host) is rejected with a {@link SecurityException}.

Review Comment:
   The phrase “a {@code create} override to another host” is ambiguous in 
Javadoc (it’s not clear what “create” refers to or what is being overridden). 
Consider clarifying the exact mechanism (e.g., overriding the generated 
`create(...)` factory / supplying an alternate base URL) so annotation users 
understand what scenarios are blocked.





> groovy-http-builder: confine requests (and redirects) to the base URI 
> ----------------------------------------------------------------------
>
>                 Key: GROOVY-12182
>                 URL: https://issues.apache.org/jira/browse/GROOVY-12182
>             Project: Groovy
>          Issue Type: Improvement
>          Components: groovy-http-builder
>            Reporter: Paul King
>            Assignee: Paul King
>            Priority: Major
>




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to