This is an automated email from the ASF dual-hosted git repository.

dashorst pushed a commit to branch wicket-10.x
in repository https://gitbox.apache.org/repos/asf/wicket.git

commit 4c1d83d306302f5b609e12b015d341f48f72cd2c
Author: Emond Papegaaij <[email protected]>
AuthorDate: Thu Sep 24 19:03:50 2026 +0200

    Judge a resumed destination on the origin that chose it
    
    RestartResponseAtInterceptPageException remembers the url of the request it
    interrupts, and continueToOriginalDestination() returns the visitor to it 
once
    they have signed in. That second request is a redirect from inside the
    application, so it is same-origin whichever origin chose the url, and
    ResourceIsolationRequestCycleListener allows it on information that is 
entirely
    correct about a request that is entirely the wrong one. Another origin can
    therefore have a listener of its choosing run once in a session that has 
just
    authenticated - a Link.onClick(), or a Form.onSubmit() carrying the field 
values
    it put in the query string, since the whole url is remembered.
    
    The two cannot be told apart at that point: a signed out visitor clicking a 
link
    on a protected page, signing in and being returned to it produces the very 
same
    same-origin GET of a remembered listener url. So record where the 
interrupted
    request came from, ask the policies about that origin as well as about the
    request making the call, and keep the stricter of the two answers.
    
    Record it on the request rather than at a throw site. Anything can throw the
    exception - a page constructor, a listener, the render of a page - and only 
the
    exception knows when a destination is being remembered, so no particular 
stack
    can be relied on to be there when it happens. onBeginRequest runs before
    resolveRequestHandler(), so what it attaches is in place ahead of all of 
them,
    and InterceptData.set() copies it whoever throws.
    
    Keep the carrying of it generic. RestartResponseAtInterceptPageException 
holds a
    MetaDataEntry[] it never looks into, moved by setInterceptMetaData() from 
the
    request cycle into the remembered destination, and back out by the request
    mapper that already recognises the resume, before that mapper clears the 
data.
    Which properties matter is the listener's business: recordOrigin() takes the
    Sec-Fetch-Site, -Mode and -Dest headers FetchMetadataResourceIsolationPolicy
    reads, the Origin and Referer OriginResourceIsolationPolicy reads, and the
    method, and is protected so that a custom policy can record what it 
consults.
    Not every header - Cookie and Authorization have no business in session 
state.
    
    The order in which the listener obtains the targeted page and consults its
    policies is left alone. The interrupted request invokes no listener: it 
renders
    the login page, which any origin may ask for. It is the resume that has to 
be
    judged, and the resume is what this changes.
    
    An interrupted request that carried no origin headers resumes into
    unknownOutcomeAction, which defaults to ABORT - the same treatment such a 
client
    already gets when it invokes a listener directly, and 
setUnknownOutcomeAction
    relaxes both together. Where nothing was recorded, because the listener was 
not
    registered, was disabled, or did not check that request, the resume is 
judged
    exactly as before.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 .../wicket/protocol/http/intercept/HomePage.html   |   3 +
 .../wicket/protocol/http/intercept/HomePage.java   |  27 ++
 .../http/intercept/InterceptedSession.java         |  54 +++
 .../wicket/protocol/http/intercept/LoginPage.html  |   5 +
 .../wicket/protocol/http/intercept/LoginPage.java  |  48 +++
 .../intercept/ResumedDestinationIsolationTest.java | 424 +++++++++++++++++++++
 .../protocol/http/intercept/SecureFormPage.html    |   5 +
 .../protocol/http/intercept/SecureFormPage.java    |  54 +++
 .../protocol/http/intercept/SecureLinkPage.html    |   5 +
 .../protocol/http/intercept/SecureLinkPage.java    |  49 +++
 .../protocol/http/intercept/SecureRenderPage.html  |   3 +
 .../protocol/http/intercept/SecureRenderPage.java  |  27 ++
 .../RestartResponseAtInterceptPageException.java   |  70 +++-
 .../ResourceIsolationRequestCycleListener.java     | 216 ++++++++++-
 .../src/main/asciidoc/security/security_5.adoc     |   2 +
 15 files changed, 976 insertions(+), 16 deletions(-)

diff --git 
a/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/HomePage.html
 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/HomePage.html
new file mode 100644
index 0000000000..33413bf938
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/HomePage.html
@@ -0,0 +1,3 @@
+<html xmlns:wicket>
+<body>home</body>
+</html>
diff --git 
a/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/HomePage.java
 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/HomePage.java
new file mode 100644
index 0000000000..35d2b80c15
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/HomePage.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.protocol.http.intercept;
+
+import org.apache.wicket.markup.html.WebPage;
+
+/**
+ * Where an unprotected visitor lands.
+ */
+public class HomePage extends WebPage
+{
+       private static final long serialVersionUID = 1L;
+}
diff --git 
a/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/InterceptedSession.java
 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/InterceptedSession.java
new file mode 100644
index 0000000000..aa0a780334
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/InterceptedSession.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.protocol.http.intercept;
+
+import org.apache.wicket.protocol.http.WebSession;
+import org.apache.wicket.request.Request;
+
+/**
+ * Session that knows whether the visitor signed in.
+ */
+public class InterceptedSession extends WebSession
+{
+       private static final long serialVersionUID = 1L;
+
+       private boolean signedIn;
+
+       /**
+        * @param request
+        */
+       public InterceptedSession(Request request)
+       {
+               super(request);
+       }
+
+       /**
+        * @return whether the visitor signed in
+        */
+       public boolean isSignedIn()
+       {
+               return signedIn;
+       }
+
+       /**
+        * Signs the visitor in.
+        */
+       public void signIn()
+       {
+               signedIn = true;
+       }
+}
diff --git 
a/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/LoginPage.html
 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/LoginPage.html
new file mode 100644
index 0000000000..4ac8426be7
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/LoginPage.html
@@ -0,0 +1,5 @@
+<html xmlns:wicket>
+<body>
+       <form wicket:id="form"><input type="submit" value="sign in"/></form>
+</body>
+</html>
diff --git 
a/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/LoginPage.java
 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/LoginPage.java
new file mode 100644
index 0000000000..70b02089d3
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/LoginPage.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.protocol.http.intercept;
+
+import org.apache.wicket.markup.html.WebPage;
+import org.apache.wicket.markup.html.form.StatelessForm;
+
+/**
+ * Signs the visitor in and returns them to where they were going, the way
+ * {@code UsernamePasswordPanel} does.
+ */
+public class LoginPage extends WebPage
+{
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * Construct.
+        */
+       public LoginPage()
+       {
+               add(new StatelessForm<Void>("form")
+               {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       protected void onSubmit()
+                       {
+                               ((InterceptedSession)getSession()).signIn();
+                               continueToOriginalDestination();
+                               setResponsePage(getApplication().getHomePage());
+                       }
+               });
+       }
+}
diff --git 
a/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/ResumedDestinationIsolationTest.java
 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/ResumedDestinationIsolationTest.java
new file mode 100644
index 0000000000..71688ddbc0
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/ResumedDestinationIsolationTest.java
@@ -0,0 +1,424 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.protocol.http.intercept;
+
+import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN;
+import static 
org.apache.wicket.protocol.http.FetchMetadataResourceIsolationPolicy.CROSS_SITE;
+import static 
org.apache.wicket.protocol.http.FetchMetadataResourceIsolationPolicy.DEST_DOCUMENT;
+import static 
org.apache.wicket.protocol.http.FetchMetadataResourceIsolationPolicy.DEST_EMBED;
+import static 
org.apache.wicket.protocol.http.FetchMetadataResourceIsolationPolicy.MODE_NAVIGATE;
+import static 
org.apache.wicket.protocol.http.FetchMetadataResourceIsolationPolicy.NONE;
+import static 
org.apache.wicket.protocol.http.FetchMetadataResourceIsolationPolicy.SAME_ORIGIN;
+import static 
org.apache.wicket.protocol.http.FetchMetadataResourceIsolationPolicy.SEC_FETCH_DEST_HEADER;
+import static 
org.apache.wicket.protocol.http.FetchMetadataResourceIsolationPolicy.SEC_FETCH_MODE_HEADER;
+import static 
org.apache.wicket.protocol.http.FetchMetadataResourceIsolationPolicy.SEC_FETCH_SITE_HEADER;
+import static java.util.Arrays.asList;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import org.apache.wicket.Page;
+import org.apache.wicket.RestartResponseAtInterceptPageException;
+import org.apache.wicket.Session;
+import org.apache.wicket.authorization.IAuthorizationStrategy;
+import org.apache.wicket.core.request.handler.IPageRequestHandler;
+import org.apache.wicket.mock.MockApplication;
+import 
org.apache.wicket.protocol.http.IResourceIsolationPolicy.ResourceIsolationOutcome;
+import org.apache.wicket.protocol.http.OriginResourceIsolationPolicy;
+import org.apache.wicket.protocol.http.ResourceIsolationRequestCycleListener;
+import 
org.apache.wicket.protocol.http.ResourceIsolationRequestCycleListener.CsrfAction;
+import org.apache.wicket.protocol.http.WebApplication;
+import org.apache.wicket.request.IRequestHandler;
+import org.apache.wicket.request.Request;
+import org.apache.wicket.request.Response;
+import org.apache.wicket.request.http.WebRequest;
+import org.apache.wicket.request.component.IRequestableComponent;
+import org.apache.wicket.util.tester.WicketTestCase;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * A request that resumes a destination remembered by
+ * {@link RestartResponseAtInterceptPageException} carries the origin that 
resumed it - this
+ * application's own, because signing in redirects within it - and not the 
origin that chose the
+ * URL. {@link ResourceIsolationRequestCycleListener} therefore judges such a 
request on what was
+ * recorded of the request that was interrupted.
+ */
+class ResumedDestinationIsolationTest extends WicketTestCase
+{
+       private static final Set<Class<? extends Page>> PROTECTED = new 
HashSet<>(
+               Arrays.asList(SecureLinkPage.class, SecureFormPage.class, 
SecureRenderPage.class));
+
+       private ResourceIsolationRequestCycleListener listener;
+
+       @Override
+       protected WebApplication newApplication()
+       {
+               return new MockApplication()
+               {
+                       @Override
+                       public Class<? extends Page> getHomePage()
+                       {
+                               return HomePage.class;
+                       }
+
+                       @Override
+                       public Session newSession(Request request, Response 
response)
+                       {
+                               return new InterceptedSession(request);
+                       }
+
+                       @Override
+                       protected void init()
+                       {
+                               super.init();
+
+                               mountPage("secure", SecureLinkPage.class);
+                               mountPage("secureform", SecureFormPage.class);
+                               mountPage("secureRender", 
SecureRenderPage.class);
+                               mountPage("login", LoginPage.class);
+
+                               // the pattern IAuthorizationStrategy 
documents, and the one both
+                               // AuthenticatedWebApplication and 
SimplePageAuthorizationStrategy implement
+                               getSecuritySettings().setAuthorizationStrategy(
+                                       new 
IAuthorizationStrategy.AllowAllAuthorizationStrategy()
+                                       {
+                                               @Override
+                                               public <T extends 
IRequestableComponent> boolean isInstantiationAuthorized(
+                                                       Class<T> componentClass)
+                                               {
+                                                       if 
(PROTECTED.contains(componentClass) &&
+                                                               
((InterceptedSession)Session.get()).isSignedIn() == false)
+                                                       {
+                                                               throw new 
RestartResponseAtInterceptPageException(LoginPage.class);
+                                                       }
+                                                       return true;
+                                               }
+                                       });
+                       }
+               };
+       }
+
+       @BeforeEach
+       void before()
+       {
+               SecureLinkPage.invoked = false;
+               SecureFormPage.submitted = null;
+               withListener(new ResourceIsolationRequestCycleListener());
+       }
+
+       /**
+        * Another origin navigates a signed out visitor to a listener URL. The 
listener is not invoked -
+        * the page is refused and the login page renders instead - but the URL 
is remembered, and after
+        * the visitor signs in the resume must not run what that origin chose.
+        */
+       @Test
+       void 
aListenerChosenByAnotherOriginIsNotInvokedWhenItsDestinationIsResumed()
+       {
+               navigate(CROSS_SITE, "secure?-1.-link");
+               tester.assertRenderedPage(LoginPage.class);
+
+               resume(signIn());
+
+               assertRefused();
+               assertFalse(SecureLinkPage.invoked, "the listener another 
origin chose was invoked");
+       }
+
+       /**
+        * The same flow, started by the visitor themselves, resumes as it 
always has.
+        */
+       @Test
+       void aListenerTheVisitorChoseIsInvokedWhenItsDestinationIsResumed()
+       {
+               navigate(SAME_ORIGIN, "secure?-1.-link");
+               tester.assertRenderedPage(LoginPage.class);
+
+               resume(signIn());
+
+               tester.assertRenderedPage(SecureLinkPage.class);
+               assertTrue(SecureLinkPage.invoked);
+       }
+
+       /**
+        * A URL the visitor typed or bookmarked reaches the listener too.
+        */
+       @Test
+       void 
aListenerOnABrowserInitiatedUrlIsInvokedWhenItsDestinationIsResumed()
+       {
+               navigate(NONE, "secure?-1.-link");
+               tester.assertRenderedPage(LoginPage.class);
+
+               resume(signIn());
+
+               tester.assertRenderedPage(SecureLinkPage.class);
+               assertTrue(SecureLinkPage.invoked);
+       }
+
+       /**
+        * The whole URL is remembered, its query string included, so a resumed 
form submission runs with
+        * the field values the interrupted request carried. A plain top-level 
navigation is enough to
+        * choose them; no form post is involved.
+        */
+       @Test
+       void 
aFormChosenByAnotherOriginIsNotSubmittedWhenItsDestinationIsResumed()
+       {
+               navigate(CROSS_SITE, 
"secureform?-1.-form&amount=CHOSEN-ELSEWHERE");
+               tester.assertRenderedPage(LoginPage.class);
+
+               resume(signIn());
+
+               assertRefused();
+               assertNull(SecureFormPage.submitted, "the form another origin 
chose was submitted");
+       }
+
+       /**
+        * The counterpart, which is what makes the previous test worth having: 
the same resume, started
+        * by the visitor themselves, is not refused.
+        * <p>
+        * The form is not submitted either, but for a reason of its own: the 
url carries no page
+        * identifier, and a listener on a stateful page is not invoked on an 
instance created for the
+        * request that carries it, so the page is rendered instead.
+        */
+       @Test
+       void aFormTheVisitorChoseIsNotRefusedWhenItsDestinationIsResumed()
+       {
+               navigate(SAME_ORIGIN, "secureform?-1.-form&amount=42");
+               tester.assertRenderedPage(LoginPage.class);
+
+               resume(signIn());
+
+               tester.assertRenderedPage(SecureFormPage.class);
+               assertNull(SecureFormPage.submitted);
+       }
+
+       /**
+        * Nothing is known about where the interrupted request came from, so 
the resume is settled by
+        * {@link 
ResourceIsolationRequestCycleListener#setUnknownOutcomeAction(CsrfAction)} - 
which is
+        * how such a client is already treated when it invokes a listener 
directly.
+        */
+       @Test
+       void aResumeIsRefusedWhenNothingWasKnownAboutTheOriginThatChoseIt()
+       {
+               navigate(null, "secure?-1.-link");
+               tester.assertRenderedPage(LoginPage.class);
+
+               resume(signIn());
+
+               assertRefused();
+               assertFalse(SecureLinkPage.invoked);
+       }
+
+       /**
+        * @see #aResumeIsRefusedWhenNothingWasKnownAboutTheOriginThatChoseIt()
+        */
+       @Test
+       void aResumeIsAllowedWhenAnUnknownOriginIsConfiguredToBeAllowed()
+       {
+               listener.setUnknownOutcomeAction(CsrfAction.ALLOW);
+
+               navigate(null, "secure?-1.-link");
+               tester.assertRenderedPage(LoginPage.class);
+
+               resume(signIn());
+
+               tester.assertRenderedPage(SecureLinkPage.class);
+               assertTrue(SecureLinkPage.invoked);
+       }
+
+       /**
+        * A page may be rendered from anywhere, so a resumed render stays 
allowed even where the
+        * listener is extended to police renders. The recorded mode, 
destination and method are what
+        * let the policy see that the interrupted request was a top level 
navigation.
+        */
+       @Test
+       void 
aRenderChosenByAnotherOriginIsStillAllowedWhenItsDestinationIsResumed()
+       {
+               withRendersChecked();
+
+               navigate(CROSS_SITE, "secureRender");
+               tester.assertRenderedPage(LoginPage.class);
+
+               resume(signIn());
+
+               tester.assertRenderedPage(SecureRenderPage.class);
+       }
+
+       /**
+        * The policies are asked about the request that chose the url, so they 
see its headers and its
+        * method, and not those of the request that resumes - which is always 
a same-origin GET
+        * navigation, whatever the interrupted request was.
+        */
+       @Test
+       void theResumeIsJudgedOnWhatTheInterruptedRequestCarried()
+       {
+               List<String> asked = new ArrayList<>();
+               withListener(new 
ResourceIsolationRequestCycleListener((request, page) -> {
+                       asked.add(request.getHeader(SEC_FETCH_SITE_HEADER) + " 
" +
+                               request.getHeader(SEC_FETCH_MODE_HEADER) + " " +
+                               request.getHeader(SEC_FETCH_DEST_HEADER) + " " 
+ request.getMethod());
+                       return ResourceIsolationOutcome.ALLOWED;
+               }));
+
+               navigate(CROSS_SITE, MODE_NAVIGATE, DEST_EMBED, "POST", 
"secure?-1.-link");
+               tester.assertRenderedPage(LoginPage.class);
+
+               String destination = signIn();
+               asked.clear();
+               resume(destination);
+
+               assertEquals(asList(SAME_ORIGIN + " " + MODE_NAVIGATE + " " + 
DEST_DOCUMENT + " GET",
+                       CROSS_SITE + " " + MODE_NAVIGATE + " " + DEST_EMBED + " 
POST"), asked,
+                       "the policies were not asked about the request that 
chose the url");
+       }
+
+       /**
+        * A client that sends no Fetch Metadata is judged by {@link 
OriginResourceIsolationPolicy}, so
+        * the {@code Origin} of the request that chose the url has to be 
recorded too. Unknown origins
+        * are allowed here, so that the recorded one is what decides.
+        */
+       @Test
+       void theOriginHeaderIsRecordedForAClientThatSendsNoFetchMetadata()
+       {
+               listener.setUnknownOutcomeAction(CsrfAction.ALLOW);
+
+               tester.addRequestHeader(WebRequest.HEADER_ORIGIN, 
"http://another.example.com";);
+               navigate(null, "secure?-1.-link");
+               tester.assertRenderedPage(LoginPage.class);
+
+               resume(signIn());
+
+               assertRefused();
+               assertFalse(SecureLinkPage.invoked);
+       }
+
+       /**
+        * What is recorded is the listener's to decide, so a policy reading 
something else can still be
+        * asked about the request that chose the URL.
+        */
+       @Test
+       void recordOriginDecidesWhatTheResumeIsJudgedOn()
+       {
+               withListener(new ResourceIsolationRequestCycleListener(
+                       (request, page) -> 
"elsewhere".equals(request.getHeader("X-From"))
+                               ? ResourceIsolationOutcome.DISALLOWED
+                               : ResourceIsolationOutcome.ALLOWED)
+               {
+                       @Override
+                       protected Map<String, String> 
recordOrigin(HttpServletRequest request)
+                       {
+                               Map<String, String> origin = 
super.recordOrigin(request);
+                               origin.put("X-From", 
request.getHeader("X-From"));
+                               return origin;
+                       }
+               });
+
+               tester.addRequestHeader("X-From", "elsewhere");
+               navigate(null, "secure?-1.-link");
+               tester.assertRenderedPage(LoginPage.class);
+
+               // neither the sign in nor the resume carries X-From
+               resume(signIn());
+
+               assertRefused();
+               assertFalse(SecureLinkPage.invoked);
+       }
+
+       /** Extends the listener to police renders as well, which it does not 
do by default. */
+       private void withRendersChecked()
+       {
+               withListener(new ResourceIsolationRequestCycleListener()
+               {
+                       @Override
+                       protected boolean isChecked(IRequestHandler handler)
+                       {
+                               return handler instanceof IPageRequestHandler;
+                       }
+               });
+       }
+
+       private void withListener(ResourceIsolationRequestCycleListener 
replacement)
+       {
+               if (listener != null)
+               {
+                       
tester.getApplication().getRequestCycleListeners().remove(listener);
+               }
+               listener = replacement;
+               
tester.getApplication().getRequestCycleListeners().add(listener);
+       }
+
+       /**
+        * A top level navigation from {@code site}, or from a client that 
sends no origin headers at
+        * all when {@code site} is {@code null}.
+        */
+       private void navigate(String site, String url)
+       {
+               navigate(site, MODE_NAVIGATE, DEST_DOCUMENT, "GET", url);
+       }
+
+       private void navigate(String site, String mode, String dest, String 
method, String url)
+       {
+               if (site != null)
+               {
+                       tester.addRequestHeader(SEC_FETCH_SITE_HEADER, site);
+                       tester.addRequestHeader(SEC_FETCH_MODE_HEADER, mode);
+                       tester.addRequestHeader(SEC_FETCH_DEST_HEADER, dest);
+               }
+               tester.getRequest().setMethod(method);
+               tester.executeUrl(url);
+       }
+
+       /**
+        * Signs in and returns the destination the sign in redirects to, 
without following it: the
+        * tester builds a fresh request for a redirect and carries no headers 
over, so the resume has
+        * to be driven the way a browser drives it.
+        */
+       private String signIn()
+       {
+               tester.addRequestHeader(SEC_FETCH_SITE_HEADER, SAME_ORIGIN);
+               tester.setFollowRedirects(false);
+               tester.newFormTester("form").submit();
+               tester.setFollowRedirects(true);
+
+               String destination = 
tester.getLastResponse().getRedirectLocation();
+               assertNotNull(destination, "signing in did not resume a 
destination");
+               return destination;
+       }
+
+       /** A browser turns the redirect that follows the sign in into a same 
origin navigation. */
+       private void resume(String destination)
+       {
+               navigate(SAME_ORIGIN, destination);
+       }
+
+       private void assertRefused()
+       {
+               assertEquals(SC_FORBIDDEN, 
tester.getLastResponse().getStatus());
+               
assertEquals(ResourceIsolationRequestCycleListener.ERROR_MESSAGE,
+                       tester.getLastResponse().getErrorMessage());
+       }
+}
diff --git 
a/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureFormPage.html
 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureFormPage.html
new file mode 100644
index 0000000000..2f360214ec
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureFormPage.html
@@ -0,0 +1,5 @@
+<html xmlns:wicket>
+<body>
+       <form wicket:id="form"><input wicket:id="amount" type="text"/></form>
+</body>
+</html>
diff --git 
a/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureFormPage.java
 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureFormPage.java
new file mode 100644
index 0000000000..2a9bd52b7a
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureFormPage.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.protocol.http.intercept;
+
+import org.apache.wicket.markup.html.WebPage;
+import org.apache.wicket.markup.html.form.Form;
+import org.apache.wicket.markup.html.form.TextField;
+import org.apache.wicket.model.Model;
+
+/**
+ * A protected page carrying an ordinary form, whose listener URL takes its 
field value from the
+ * query string when the request is a GET.
+ */
+public class SecureFormPage extends WebPage
+{
+       private static final long serialVersionUID = 1L;
+
+       /** The value {@code onSubmit()} saw, or {@code null} when it did not 
run. */
+       public static String submitted;
+
+       /**
+        * Construct.
+        */
+       public SecureFormPage()
+       {
+               Model<String> amount = Model.of();
+               Form<Void> form = new Form<>("form")
+               {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       protected void onSubmit()
+                       {
+                               submitted = amount.getObject();
+                       }
+               };
+               form.add(new TextField<>("amount", amount));
+               add(form);
+       }
+}
diff --git 
a/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureLinkPage.html
 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureLinkPage.html
new file mode 100644
index 0000000000..2a5a0a1604
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureLinkPage.html
@@ -0,0 +1,5 @@
+<html xmlns:wicket>
+<body>
+       <a wicket:id="link">a listener on a protected page</a>
+</body>
+</html>
diff --git 
a/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureLinkPage.java
 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureLinkPage.java
new file mode 100644
index 0000000000..f207d3a754
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureLinkPage.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.protocol.http.intercept;
+
+import org.apache.wicket.markup.html.WebPage;
+import org.apache.wicket.markup.html.link.StatelessLink;
+
+/**
+ * A protected page whose listener is reachable on a URL without a page 
identifier, because the page
+ * is stateless.
+ */
+public class SecureLinkPage extends WebPage
+{
+       private static final long serialVersionUID = 1L;
+
+       /** Whether the link's listener ran. */
+       public static boolean invoked;
+
+       /**
+        * Construct.
+        */
+       public SecureLinkPage()
+       {
+               add(new StatelessLink<Void>("link")
+               {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       public void onClick()
+                       {
+                               invoked = true;
+                       }
+               });
+       }
+}
diff --git 
a/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureRenderPage.html
 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureRenderPage.html
new file mode 100644
index 0000000000..b13b3983af
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureRenderPage.html
@@ -0,0 +1,3 @@
+<html xmlns:wicket>
+<body>a protected page</body>
+</html>
diff --git 
a/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureRenderPage.java
 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureRenderPage.java
new file mode 100644
index 0000000000..5e20839c82
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/protocol/http/intercept/SecureRenderPage.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.protocol.http.intercept;
+
+import org.apache.wicket.markup.html.WebPage;
+
+/**
+ * A protected page that is only ever rendered.
+ */
+public class SecureRenderPage extends WebPage
+{
+       private static final long serialVersionUID = 1L;
+}
diff --git 
a/wicket-core/src/main/java/org/apache/wicket/RestartResponseAtInterceptPageException.java
 
b/wicket-core/src/main/java/org/apache/wicket/RestartResponseAtInterceptPageException.java
index c34fcb15be..3a1ef4142e 100644
--- 
a/wicket-core/src/main/java/org/apache/wicket/RestartResponseAtInterceptPageException.java
+++ 
b/wicket-core/src/main/java/org/apache/wicket/RestartResponseAtInterceptPageException.java
@@ -112,6 +112,66 @@ public class RestartResponseAtInterceptPageException 
extends ResetResponseExcept
                return postParameters;
        }
 
+       /**
+        * Attaches data to the request that is being handled, to be remembered 
along with the
+        * destination should this request be intercepted. The request that 
later resumes that
+        * destination reads it back with {@link 
#getInterceptMetaData(MetaDataKey)}.
+        * <p>
+        * A request is not intercepted where it is interrupted: any code can 
throw this exception - a
+        * page constructor, a listener, the render of a page - and one request 
can be intercepted more
+        * than once, each interception replacing what the previous one 
remembered. Data attached here
+        * travels with the destination whichever of those happens, which data 
collected at a particular
+        * throw site cannot.
+        * <p>
+        * The data is remembered in the session, so it has to be serializable, 
and it is handed to every
+        * request that resumes the destination, so it should be immutable.
+        * 
+        * @param <T>
+        *            the type of the data
+        * @param key
+        *            the key to attach the data under
+        * @param data
+        *            the data, or {@code null} to remove what was attached 
under {@code key}
+        */
+       public static <T> void setInterceptMetaData(MetaDataKey<T> key, T data)
+       {
+               RequestCycle cycle = RequestCycle.get();
+               if (cycle != null)
+               {
+                       cycle.setMetaData(pendingMetaDataKey,
+                               key.set(cycle.getMetaData(pendingMetaDataKey), 
data));
+               }
+       }
+
+       /**
+        * The data that {@link #setInterceptMetaData(MetaDataKey, Object)} 
attached to the request whose
+        * destination the current request resumes.
+        * 
+        * @param <T>
+        *            the type of the data
+        * @param key
+        *            the key the data was attached under
+        * @return the data, or {@code null} when the current request resumes 
no destination, or when
+        *         nothing was attached under {@code key}
+        */
+       public static <T> T getInterceptMetaData(MetaDataKey<T> key)
+       {
+               RequestCycle cycle = RequestCycle.get();
+               return cycle == null ? null : 
key.get(cycle.getMetaData(resumedMetaDataKey));
+       }
+
+       /** What is attached to the request being handled, in case it is 
intercepted. */
+       private static final MetaDataKey<MetaDataEntry<?>[]> pendingMetaDataKey 
= new MetaDataKey<>()
+       {
+               private static final long serialVersionUID = 1L;
+       };
+
+       /** What was attached to the request whose destination the current 
request resumes. */
+       private static final MetaDataKey<MetaDataEntry<?>[]> resumedMetaDataKey 
= new MetaDataKey<>()
+       {
+               private static final long serialVersionUID = 1L;
+       };
+
        /**
         * INTERNAL CLASS, DO NOT USE
         * 
@@ -123,6 +183,7 @@ public class RestartResponseAtInterceptPageException 
extends ResetResponseExcept
 
                private Url originalUrl;
                private Map<String, List<StringValue>> postParameters;
+               private MetaDataEntry<?>[] metaData;
 
                public Url getOriginalUrl()
                {
@@ -139,7 +200,9 @@ public class RestartResponseAtInterceptPageException 
extends ResetResponseExcept
                        Session session = Session.get();
                        session.bind();
                        InterceptData data = new InterceptData();
-                       Request request = RequestCycle.get().getRequest();
+                       RequestCycle cycle = RequestCycle.get();
+                       Request request = cycle.getRequest();
+                       data.metaData = cycle.getMetaData(pendingMetaDataKey);
                        data.originalUrl = request.getOriginalUrl();
                        Iterator<QueryParameter> itor = 
data.originalUrl.getQueryParameters().iterator();
                        while (itor.hasNext())
@@ -236,6 +299,11 @@ public class RestartResponseAtInterceptPageException 
extends ResetResponseExcept
                                                
parameters.setParameterValues(s, data.postParameters.get(s));
                                        }
                                }
+                               RequestCycle cycle = RequestCycle.get();
+                               if (cycle != null)
+                               {
+                                       cycle.setMetaData(resumedMetaDataKey, 
data.metaData);
+                               }
                                InterceptData.clear();
                        }
                        return null;
diff --git 
a/wicket-core/src/main/java/org/apache/wicket/protocol/http/ResourceIsolationRequestCycleListener.java
 
b/wicket-core/src/main/java/org/apache/wicket/protocol/http/ResourceIsolationRequestCycleListener.java
index 4ad3792870..070403e454 100644
--- 
a/wicket-core/src/main/java/org/apache/wicket/protocol/http/ResourceIsolationRequestCycleListener.java
+++ 
b/wicket-core/src/main/java/org/apache/wicket/protocol/http/ResourceIsolationRequestCycleListener.java
@@ -20,13 +20,22 @@ import static java.util.Arrays.asList;
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
 
 import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
 import jakarta.servlet.http.HttpServletResponse;
 
+import org.apache.wicket.MetaDataKey;
+import org.apache.wicket.RestartResponseAtInterceptPageException;
 import org.apache.wicket.RestartResponseException;
 import org.apache.wicket.core.request.handler.IPageRequestHandler;
 import org.apache.wicket.core.request.handler.RenderPageRequestHandler;
@@ -37,6 +46,7 @@ import org.apache.wicket.request.IRequestHandlerDelegate;
 import org.apache.wicket.request.component.IRequestablePage;
 import org.apache.wicket.request.cycle.IRequestCycleListener;
 import org.apache.wicket.request.cycle.RequestCycle;
+import org.apache.wicket.request.http.WebRequest;
 import org.apache.wicket.request.http.WebResponse;
 import org.apache.wicket.request.http.flow.AbortWithHttpErrorCodeException;
 import org.apache.wicket.util.lang.Classes;
@@ -155,6 +165,25 @@ public class ResourceIsolationRequestCycleListener 
implements IRequestCycleListe
        
        private final List<IResourceIsolationPolicy> resourceIsolationPolicies 
= new ArrayList<>();
 
+       /**
+        * The key {@link #recordOrigin(HttpServletRequest)} records the 
request method under. Not a
+        * valid header name, so it cannot collide with one.
+        */
+       protected static final String RECORDED_METHOD = ":method";
+
+       /** The headers {@link #recordOrigin(HttpServletRequest)} records by 
default. */
+       private static final List<String> RECORDED_HEADERS = asList(
+               FetchMetadataResourceIsolationPolicy.SEC_FETCH_SITE_HEADER,
+               FetchMetadataResourceIsolationPolicy.SEC_FETCH_MODE_HEADER,
+               FetchMetadataResourceIsolationPolicy.SEC_FETCH_DEST_HEADER, 
WebRequest.HEADER_ORIGIN,
+               WebRequest.HEADER_REFERER);
+
+       /** What {@link #recordOrigin(HttpServletRequest)} recorded, carried 
with a destination. */
+       private static final MetaDataKey<Map<String, String>> ORIGIN = new 
MetaDataKey<>()
+       {
+               private static final long serialVersionUID = 1L;
+       };
+
        /**
         * Create a new listener with the given policies. If no policies are 
given,
         * {@link FetchMetadataResourceIsolationPolicy} and {@link 
OriginResourceIsolationPolicy} will
@@ -244,6 +273,44 @@ public class ResourceIsolationRequestCycleListener 
implements IRequestCycleListe
                        .getContainerRequest();
 
                log.debug("Processing request to: {}", 
containerRequest.getPathInfo());
+
+               if (isEnabled())
+               {
+                       // Record where this request came from while that is 
still known. Should the application
+                       // interrupt this request and remember its destination 
to be resumed after a sign in,
+                       // the request that resumes it will have been 
redirected from inside this application
+                       // and will say so: it carries the origin that resumed 
it, not the origin that chose the
+                       // URL. This record is what lets that request be judged 
on the latter.
+                       
RestartResponseAtInterceptPageException.setInterceptMetaData(ORIGIN,
+                               recordOrigin(containerRequest));
+               }
+       }
+
+       /**
+        * Records what the isolation policies need in order to tell where a 
request came from. Called
+        * for every request, and remembered with the destination whenever a 
request is interrupted and
+        * its destination kept to be resumed later.
+        * <p>
+        * Override to record what a custom {@link IResourceIsolationPolicy} 
reads. Record an entry for
+        * every header consulted, with a {@code null} value where the header 
was absent: a name the
+        * record does not mention at all is answered by the request that 
resumes the destination, which
+        * is never what a header deciding provenance should fall back to. Keys 
are matched ignoring
+        * case, and the record is held in the session, so its values have to 
be serializable.
+        *
+        * @param request
+        *            the request being handled
+        * @return the recorded properties, keyed by header name, with the 
request method under
+        *         {@link #RECORDED_METHOD}
+        */
+       protected Map<String, String> recordOrigin(HttpServletRequest request)
+       {
+               Map<String, String> origin = new HashMap<>();
+               for (String header : RECORDED_HEADERS)
+               {
+                       origin.put(header, request.getHeader(header));
+               }
+               origin.put(RECORDED_METHOD, request.getMethod());
+               return origin;
        }
 
        /**
@@ -344,23 +411,30 @@ public class ResourceIsolationRequestCycleListener 
implements IRequestCycleListe
 
                        RequestType requestType = getRequestType(handler);
 
-                       for (IResourceIsolationPolicy policy : 
resourceIsolationPolicies)
+                       ResourceIsolationOutcome outcome = 
outcomeFor(containerRequest, targetedPage,
+                               requestType);
+
+                       Map<String, String> origin = 
RestartResponseAtInterceptPageException
+                               .getInterceptMetaData(ORIGIN);
+                       if (origin != null)
                        {
-                               ResourceIsolationOutcome outcome = 
policy.isRequestAllowed(containerRequest,
-                                       targetedPage, requestType);
-                               if 
(ResourceIsolationOutcome.DISALLOWED.equals(outcome))
-                               {
-                                       log.debug("Isolation policy {} has 
rejected a request to {}",
-                                               
Classes.simpleName(policy.getClass()), pathInfo);
-                                       disallowedOutcomeAction.apply(this, 
containerRequest, targetedPage);
-                                       return;
-                               }
-                               else if 
(ResourceIsolationOutcome.ALLOWED.equals(outcome))
-                               {
-                                       return;
-                               }
+                               // This request resumes a destination that an 
earlier request chose. It carries the
+                               // origin that resumed it - this application's 
own, because signing in redirects
+                               // within it - so its own headers say nothing 
about where the URL came from. Judge
+                               // the request that did choose it as well, and 
keep the stricter of the two.
+                               outcome = stricter(outcome,
+                                       outcomeFor(new 
RecordedOriginRequest(containerRequest, origin), targetedPage,
+                                               requestType));
+                       }
+
+                       if (ResourceIsolationOutcome.DISALLOWED.equals(outcome))
+                       {
+                               disallowedOutcomeAction.apply(this, 
containerRequest, targetedPage);
+                       }
+                       else if 
(ResourceIsolationOutcome.UNKNOWN.equals(outcome))
+                       {
+                               unknownOutcomeAction.apply(this, 
containerRequest, targetedPage);
                        }
-                       unknownOutcomeAction.apply(this, containerRequest, 
targetedPage);
                }
                else
                {
@@ -437,6 +511,47 @@ public class ResourceIsolationRequestCycleListener 
implements IRequestCycleListe
                throw new AbortWithHttpErrorCodeException(errorCode, 
errorMessage);
        }
 
+       /**
+        * The outcome the policies reach for {@code request}: the first policy 
that does not answer
+        * {@link ResourceIsolationOutcome#UNKNOWN} decides.
+        */
+       private ResourceIsolationOutcome outcomeFor(HttpServletRequest request,
+               IRequestablePage targetedPage, RequestType requestType)
+       {
+               for (IResourceIsolationPolicy policy : 
resourceIsolationPolicies)
+               {
+                       ResourceIsolationOutcome outcome = 
policy.isRequestAllowed(request, targetedPage,
+                               requestType);
+                       if (ResourceIsolationOutcome.DISALLOWED.equals(outcome))
+                       {
+                               log.debug("Isolation policy {} has rejected a 
request to {}",
+                                       Classes.simpleName(policy.getClass()), 
request.getPathInfo());
+                               return outcome;
+                       }
+                       else if 
(ResourceIsolationOutcome.ALLOWED.equals(outcome))
+                       {
+                               return outcome;
+                       }
+               }
+               return ResourceIsolationOutcome.UNKNOWN;
+       }
+
+       private static ResourceIsolationOutcome 
stricter(ResourceIsolationOutcome one,
+               ResourceIsolationOutcome other)
+       {
+               if (ResourceIsolationOutcome.DISALLOWED.equals(one) ||
+                       ResourceIsolationOutcome.DISALLOWED.equals(other))
+               {
+                       return ResourceIsolationOutcome.DISALLOWED;
+               }
+               if (ResourceIsolationOutcome.UNKNOWN.equals(one) ||
+                       ResourceIsolationOutcome.UNKNOWN.equals(other))
+               {
+                       return ResourceIsolationOutcome.UNKNOWN;
+               }
+               return ResourceIsolationOutcome.ALLOWED;
+       }
+
        private static IRequestHandler unwrap(IRequestHandler handler)
        {
                while (handler instanceof IRequestHandlerDelegate)
@@ -445,4 +560,75 @@ public class ResourceIsolationRequestCycleListener 
implements IRequestCycleListe
                }
                return handler;
        }
+
+       /**
+        * Presents the request that was interrupted, so that the policies 
judge the origin that chose a
+        * resumed URL rather than the origin that resumed it.
+        * <p>
+        * Whatever the record mentions is answered from the record alone, its 
absences included: a
+        * resumed request always carries this application's own origin, so 
falling through to it would
+        * hand back the very answer this class exists to avoid. Everything 
else is answered by the
+        * resuming request, whose target scheme, host and port are the same 
ones the interrupted
+        * request was compared against.
+        */
+       private static class RecordedOriginRequest extends 
HttpServletRequestWrapper
+       {
+               private final Map<String, String> origin;
+
+               private RecordedOriginRequest(HttpServletRequest request, 
Map<String, String> origin)
+               {
+                       super(request);
+
+                       // header names are matched ignoring case, whatever 
recordOrigin() returned
+                       this.origin = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+                       this.origin.putAll(origin);
+               }
+
+               @Override
+               public String getMethod()
+               {
+                       return origin.containsKey(RECORDED_METHOD) ? 
origin.get(RECORDED_METHOD)
+                               : super.getMethod();
+               }
+
+               @Override
+               public String getHeader(String name)
+               {
+                       return origin.containsKey(name) ? origin.get(name) : 
super.getHeader(name);
+               }
+
+               @Override
+               public Enumeration<String> getHeaders(String name)
+               {
+                       if (origin.containsKey(name))
+                       {
+                               String value = origin.get(name);
+                               return Collections.enumeration(
+                                       value == null ? Collections.emptyList() 
: Collections.singletonList(value));
+                       }
+                       return super.getHeaders(name);
+               }
+
+               @Override
+               public Enumeration<String> getHeaderNames()
+               {
+                       Set<String> names = new 
TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+                       for (Enumeration<String> live = super.getHeaderNames(); 
live.hasMoreElements();)
+                       {
+                               String name = live.nextElement();
+                               if (origin.containsKey(name) == false)
+                               {
+                                       names.add(name);
+                               }
+                       }
+                       for (Map.Entry<String, String> recorded : 
origin.entrySet())
+                       {
+                               if (recorded.getValue() != null && 
RECORDED_METHOD.equals(recorded.getKey()) == false)
+                               {
+                                       names.add(recorded.getKey());
+                               }
+                       }
+                       return Collections.enumeration(names);
+               }
+       }
 }
diff --git a/wicket-user-guide/src/main/asciidoc/security/security_5.adoc 
b/wicket-user-guide/src/main/asciidoc/security/security_5.adoc
index ecab4acb02..93f824b6d2 100644
--- a/wicket-user-guide/src/main/asciidoc/security/security_5.adoc
+++ b/wicket-user-guide/src/main/asciidoc/security/security_5.adoc
@@ -65,6 +65,8 @@ Because a _same-site_ request is cross-origin, it is treated 
as one: by default
 
 Note that this decision rests entirely with the Fetch Metadata policy: 
__OriginResourceIsolationPolicy__ is only consulted for requests that carry no 
Fetch Metadata headers at all, which no current browser does.
 
+A request that is interrupted and resumed later is judged on where it 
originally came from. An application that refuses an unauthenticated visitor by 
throwing _RestartResponseAtInterceptPageException_ has the url that visitor 
asked for remembered, and _continueToOriginalDestination()_ returns them to it 
once they have signed in. That second request is a redirect from inside our own 
application, so it says _same-origin_ no matter which origin chose the url in 
the first place. The listener [...]
+
 If every origin on our site is trusted - for instance when all of our 
subdomains are part of the same application - we can allow sibling origins to 
invoke listeners as well with _setSameSiteAllowed(true)_:
 
 [source,java]

Reply via email to