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 195b89c9cd77f8b718fae05fd6ce6690f8680472 Author: Emond Papegaaij <[email protected]> AuthorDate: Thu Sep 10 13:21:13 2026 +0200 Ask IAuthorizationStrategy about a resource served by a component isResourceAuthorized() is consulted in one place, the constructor of ResourceRequestHandler, which ResourceReferenceRequestHandler builds for every resource requested through a mounted or shared ResourceReference. A component handed an IResource instance instead built IResource.Attributes itself and called IResource#respond() from its own request listener, so it never reached that constructor and the strategy was never asked: ResourceLink, Image - through LocalizedImageResource, for its src and for every srcset entry - ImageButton, and AjaxDownloadBehavior in wicket-extensions. Which of the two happened was decided by the constructor the application used and by nothing in the rendered page: ResourceLink(String, ResourceReference) renders a resource URL and was authorized, ResourceLink(String, IResource) renders a listener URL and was not. Serve those resources through a ResourceRequestHandler as well, which is what ResourceReferenceRequestHandler#respond() already does for the other route. That keeps one enforcement point rather than adding a second one that can drift, and needs no new API. IResource#respond() states that rule in its javadoc, at the method a component would otherwise call directly. LocalizedImageResource#onResourceRequested() is final on a final class, so the single edit there covers Image and ImageButton both. In AjaxDownloadBehavior the handler is constructed before the completion cookie is added, because the constructor is what asks the strategy: wicket-ajaxdownload.js reads that cookie before it looks at the response body, and a buffered response replays a cookie ahead of the error, so a refused download that had already set it would be reported as a success. ExportToolbar builds its export links from an IResource, so a data table export is authorized now as well. ResourceStreamRequestHandler, which DownloadLink schedules, still streams without asking; the resource it serves is one it constructs itself around an IResourceStream, so no application restriction was ever attached to it. Attributes#getParameters() on these routes is now the empty PageParameters that ResourceRequestHandler substitutes rather than null, which is what the mounted route has always passed, and the strategy is never asked with null. The parameters a component holds are not forwarded: ResourceLink leaves them null on this route by construction, and the ones in LocalizedImageResource belong to the reference route. An application that restricts a resource and hands it to one of those components now gets the refusal it configured where the resource used to be served. ResourceLink#onClick() no longer runs for a refused resource; it used to run after the bytes had been written. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --- .../role/annotations/AuthorizeResource.java | 4 + .../annotations/AnnotationsRoleResourceTest.java | 220 +++++++++ .../ComponentResourceAuthorizationTest.java | 489 +++++++++++++++++++++ .../authorization/IAuthorizationStrategy.java | 16 +- .../image/resource/LocalizedImageResource.java | 28 +- .../wicket/markup/html/link/ResourceLink.java | 30 +- .../apache/wicket/request/resource/IResource.java | 10 +- .../extensions/ajax/AjaxDownloadBehavior.java | 24 +- .../AjaxDownloadResourceAuthorizationTest.java | 265 +++++++++++ .../ExportToolbarResourceAuthorizationTest.java | 155 +++++++ 10 files changed, 1223 insertions(+), 18 deletions(-) diff --git a/wicket-auth-roles/src/main/java/org/apache/wicket/authroles/authorization/strategies/role/annotations/AuthorizeResource.java b/wicket-auth-roles/src/main/java/org/apache/wicket/authroles/authorization/strategies/role/annotations/AuthorizeResource.java index 9162f51a6b..34cddfe76d 100644 --- a/wicket-auth-roles/src/main/java/org/apache/wicket/authroles/authorization/strategies/role/annotations/AuthorizeResource.java +++ b/wicket-auth-roles/src/main/java/org/apache/wicket/authroles/authorization/strategies/role/annotations/AuthorizeResource.java @@ -26,6 +26,10 @@ import java.lang.annotation.Target; /** * Annotation for configuring what roles are allowed for requesting the annotated resource. It works analogously * to {@link org.apache.wicket.authroles.authorization.strategies.role.annotations.AuthorizeInstantiation}. + * <p> + * It applies to every request that serves the annotated resource: one requested by the URL of a + * mounted or shared {@link org.apache.wicket.request.resource.ResourceReference}, and one served + * through a component that was handed the resource itself. * * @author Carl-Eric Menzel * @see org.apache.wicket.authorization.IAuthorizationStrategy diff --git a/wicket-auth-roles/src/test/java/org/apache/wicket/authroles/authorization/strategies/role/annotations/AnnotationsRoleResourceTest.java b/wicket-auth-roles/src/test/java/org/apache/wicket/authroles/authorization/strategies/role/annotations/AnnotationsRoleResourceTest.java new file mode 100644 index 0000000000..236f8587c3 --- /dev/null +++ b/wicket-auth-roles/src/test/java/org/apache/wicket/authroles/authorization/strategies/role/annotations/AnnotationsRoleResourceTest.java @@ -0,0 +1,220 @@ +/* + * 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.authroles.authorization.strategies.role.annotations; + +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.assertTrue; + +import java.io.Serializable; + +import jakarta.servlet.http.HttpServletResponse; + +import org.apache.wicket.MarkupContainer; +import org.apache.wicket.authroles.authorization.strategies.role.IRoleCheckingStrategy; +import org.apache.wicket.authroles.authorization.strategies.role.RoleAuthorizationStrategy; +import org.apache.wicket.authroles.authorization.strategies.role.Roles; +import org.apache.wicket.markup.IMarkupResourceStreamProvider; +import org.apache.wicket.markup.html.WebPage; +import org.apache.wicket.markup.html.image.Image; +import org.apache.wicket.markup.html.link.ResourceLink; +import org.apache.wicket.request.resource.IResource; +import org.apache.wicket.util.resource.IResourceStream; +import org.apache.wicket.util.resource.StringResourceStream; +import org.apache.wicket.util.tester.TagTester; +import org.apache.wicket.util.tester.WicketTester; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * A resource reserved for a role with {@link AuthorizeResource} must not be served to a user who + * does not hold that role, not even through the component that was handed the resource itself. + */ +class AnnotationsRoleResourceTest +{ + private WicketTester tester; + + @BeforeEach + void setUp() + { + tester = new WicketTester(); + } + + @AfterEach + void tearDown() + { + tester.destroy(); + } + + private void signInAs(String role) + { + tester.getApplication() + .getSecuritySettings() + .setAuthorizationStrategy(new RoleAuthorizationStrategy(new UserRolesAuthorizer(role))); + } + + /** + * The listener URL Wicket itself rendered for the component with the given wicket:id. + */ + private String urlInMarkup(String wicketId, String attribute) + { + TagTester tag = tester.getTagByWicketId(wicketId); + assertNotNull(tag, "no tag with wicket:id '" + wicketId + "' was rendered"); + + String url = tag.getAttribute(attribute); + assertNotNull(url, "the tag with wicket:id '" + wicketId + "' has no " + attribute); + + return url; + } + + @Test + void adminMayDownloadTheAdminResourceThroughAResourceLink() + { + signInAs("ADMIN"); + TestPage page = new TestPage(); + tester.startPage(page); + + tester.clickLink("admin", false); + + assertEquals(HttpServletResponse.SC_OK, tester.getLastResponse().getStatus()); + assertTrue(page.admin.responded, "the resource did not stream for an ADMIN"); + } + + @Test + void userMayNotDownloadTheAdminResourceThroughAResourceLink() + { + signInAs("USER"); + TestPage page = new TestPage(); + tester.startPage(page); + + tester.clickLink("admin", false); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, tester.getLastResponse().getStatus(), + "the request should have been refused"); + assertFalse(page.admin.responded, + "the resource streamed to a user who does not hold the ADMIN role"); + } + + @Test + void userMayNotDownloadTheAdminResourceThroughAnImage() + { + signInAs("USER"); + TestPage page = new TestPage(); + tester.startPage(page); + + tester.executeUrl(urlInMarkup("image", "src")); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, tester.getLastResponse().getStatus(), + "the request should have been refused"); + assertFalse(page.image.responded, + "the resource streamed to a user who does not hold the ADMIN role"); + } + + /** + * Control: a resource that carries no annotation is not restricted. + */ + @Test + void userMayStillDownloadAnUnannotatedResource() + { + signInAs("USER"); + TestPage page = new TestPage(); + tester.startPage(page); + + tester.clickLink("open", false); + + assertEquals(HttpServletResponse.SC_OK, tester.getLastResponse().getStatus()); + assertTrue(page.open.responded, "an unannotated resource must still be served"); + } + + /** A page anyone may open, carrying resources reserved for ADMIN. */ + public static class TestPage extends WebPage implements IMarkupResourceStreamProvider + { + private static final long serialVersionUID = 1L; + + final AdminResource admin = new AdminResource(); + + final AdminResource image = new AdminResource(); + + final OpenResource open = new OpenResource(); + + TestPage() + { + setStatelessHint(false); + + add(new ResourceLink<Void>("admin", admin)); + add(new ResourceLink<Void>("open", open)); + add(new Image("image", image)); + } + + @Override + public IResourceStream getMarkupResourceStream(MarkupContainer container, + Class<?> containerClass) + { + return new StringResourceStream("<html><body>" // + + "<a wicket:id=\"admin\">admin</a>" // + + "<a wicket:id=\"open\">open</a>" // + + "<img wicket:id=\"image\"/>" // + + "</body></html>"); + } + } + + /** Records that it was asked to respond. The annotation cannot go on an anonymous class. */ + private static class RecordingResource implements IResource + { + private static final long serialVersionUID = 1L; + + boolean responded; + + @Override + public void respond(Attributes attributes) + { + responded = true; + attributes.getResponse().write("streamed"); + } + } + + @AuthorizeResource("ADMIN") + private static class AdminResource extends RecordingResource + { + private static final long serialVersionUID = 1L; + } + + private static class OpenResource extends RecordingResource + { + private static final long serialVersionUID = 1L; + } + + private static final class UserRolesAuthorizer implements IRoleCheckingStrategy, Serializable + { + private static final long serialVersionUID = 1L; + + private final Roles roles; + + UserRolesAuthorizer(String roles) + { + this.roles = new Roles(roles); + } + + @Override + public boolean hasAnyRole(Roles roles) + { + return this.roles.hasAnyRole(roles); + } + } +} diff --git a/wicket-core-tests/src/test/java/org/apache/wicket/core/request/handler/resource/ComponentResourceAuthorizationTest.java b/wicket-core-tests/src/test/java/org/apache/wicket/core/request/handler/resource/ComponentResourceAuthorizationTest.java new file mode 100644 index 0000000000..9de9fec5bf --- /dev/null +++ b/wicket-core-tests/src/test/java/org/apache/wicket/core/request/handler/resource/ComponentResourceAuthorizationTest.java @@ -0,0 +1,489 @@ +/* + * 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.core.request.handler.resource; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import jakarta.servlet.http.HttpServletResponse; + +import org.apache.wicket.MarkupContainer; +import org.apache.wicket.authorization.IAuthorizationStrategy; +import org.apache.wicket.markup.IMarkupResourceStreamProvider; +import org.apache.wicket.markup.html.WebPage; +import org.apache.wicket.markup.html.form.Form; +import org.apache.wicket.markup.html.form.ImageButton; +import org.apache.wicket.markup.html.image.Image; +import org.apache.wicket.markup.html.link.ResourceLink; +import org.apache.wicket.model.Model; +import org.apache.wicket.request.mapper.parameter.PageParameters; +import org.apache.wicket.request.resource.IResource; +import org.apache.wicket.request.resource.ResourceReference; +import org.apache.wicket.util.lang.Exceptions; +import org.apache.wicket.util.resource.IResourceStream; +import org.apache.wicket.util.resource.StringResourceStream; +import org.apache.wicket.util.tester.TagTester; +import org.apache.wicket.util.tester.WicketTestCase; +import org.junit.jupiter.api.Test; + +/** + * A resource a component was handed as an {@link IResource} is served from the listener URL of that + * component rather than from a resource URL of its own, and the {@link IAuthorizationStrategy} has + * to authorize it there just as it does when the same instance is requested through a + * {@link ResourceReference}. + * <p> + * {@link ResourceAuthorizationTest} covers the decision itself on the handler that makes it; this + * covers the components that have to reach that handler. + */ +class ComponentResourceAuthorizationTest extends WicketTestCase +{ + /** Every resource the strategy was asked about, in the order it was asked. */ + private final List<IResource> asked = new ArrayList<>(); + + /** The parameters of the last question put to the strategy. */ + private PageParameters askedWith; + + /** + * Refuses the given resources, allows every other one, and records every question asked. + */ + private void deny(final IResource... denied) + { + final List<IResource> refused = Arrays.asList(denied); + + tester.getApplication() + .getSecuritySettings() + .setAuthorizationStrategy(new IAuthorizationStrategy.AllowAllAuthorizationStrategy() + { + @Override + public boolean isResourceAuthorized(IResource resource, PageParameters parameters) + { + asked.add(resource); + askedWith = parameters; + return refused.contains(resource) == false; + } + }); + } + + /** + * The URL Wicket itself rendered for the component with the given wicket:id, which for a + * component holding an {@link IResource} is the listener URL of that component. It carries the + * identifier of the page that rendered it, so the request reaches that very instance and the + * listener gate of the component is in full effect. + */ + private String urlInMarkup(String wicketId, String attribute) + { + TagTester tag = tester.getTagByWicketId(wicketId); + assertNotNull(tag, "no tag with wicket:id '" + wicketId + "' was rendered"); + + String url = tag.getAttribute(attribute); + assertNotNull(url, "the tag with wicket:id '" + wicketId + "' has no " + attribute); + assertTrue(url.contains("?" + tester.getLastRenderedPage().getPageId() + "-"), + "expected a listener URL carrying the page identifier, was " + url); + + return url; + } + + private void assertRefused(RecordingResource... resources) + { + assertEquals(HttpServletResponse.SC_FORBIDDEN, tester.getLastResponse().getStatus(), + "the request should have been refused"); + + for (RecordingResource resource : resources) + { + assertFalse(resource.responded, + "resource '" + resource + "' streamed although the strategy refused it"); + assertFalse(tester.getLastResponseAsString().contains(resource.name), + "the content of resource '" + resource + "' reached the response"); + } + } + + private void assertRefusalMessage(RecordingResource resource) + { + assertEquals("The request to resource '" + resource + + "' with parameters '' cannot be authorized.", + tester.getLastResponse().getErrorMessage()); + } + + private void assertStreamed(String content, RecordingResource resource) + { + assertEquals(HttpServletResponse.SC_OK, tester.getLastResponse().getStatus()); + assertTrue(resource.responded, "resource '" + resource + "' did not stream"); + assertEquals(content, tester.getLastResponseAsString()); + } + + @Test + void resourceLinkWithDeniedResourceIsRefused() + { + ResourceLinkPage page = new ResourceLinkPage(); + deny(page.denied); + tester.startPage(page); + assertFalse(page.isPageStateless()); + + tester.clickLink("denied", false); + + assertRefused(page.denied); + assertRefusalMessage(page.denied); + assertFalse(page.deniedClicked, + "ResourceLink#onClick() ran although the resource request was refused"); + } + + /** + * Control: the fix must not refuse a resource the strategy allows. + */ + @Test + void resourceLinkWithAllowedResourceIsStillServed() + { + ResourceLinkPage page = new ResourceLinkPage(); + deny(page.denied); + tester.startPage(page); + + tester.clickLink("allowed", false); + + assertStreamed("allowed", page.allowed); + } + + @Test + void resourceLinkWithDeniedResourceReferenceIsStillRefused() + { + ResourceLinkPage page = new ResourceLinkPage(); + deny(page.referenced); + tester.startPage(page); + + tester.clickLink("reference", false); + + assertRefused(page.referenced); + } + + /** + * Control: the other constructor of the same component renders a resource URL, which is the + * route that was authorized all along. + */ + @Test + void resourceLinkWithAllowedResourceReferenceIsStillServed() + { + ResourceLinkPage page = new ResourceLinkPage(); + deny(page.denied); + tester.startPage(page); + + String href = tester.getTagByWicketId("reference").getAttribute("href"); + assertTrue(href.contains("resource/"), "expected a resource URL, was " + href); + + tester.clickLink("reference", false); + + assertStreamed("referenced", page.referenced); + } + + @Test + void imageWithDeniedResourceIsRefused() + { + ImagePage page = new ImagePage(); + deny(page.src); + tester.startPage(page); + + tester.executeUrl(urlInMarkup("src", "src")); + + assertRefused(page.src); + assertRefusalMessage(page.src); + } + + /** + * Control. + */ + @Test + void imageWithAllowedResourceIsStillServed() + { + ImagePage page = new ImagePage(); + deny(page.fromModel); + tester.startPage(page); + + tester.executeUrl(urlInMarkup("src", "src")); + + assertStreamed("src", page.src); + } + + @Test + void imageWithDeniedResourceFromItsModelIsRefused() + { + ImagePage page = new ImagePage(); + deny(page.fromModel); + tester.startPage(page); + + tester.executeUrl(urlInMarkup("model", "src")); + + assertRefused(page.fromModel); + } + + @Test + void deniedSrcSetEntryOfAnImageIsRefused() + { + ImagePage page = new ImagePage(); + deny(page.setEntry); + tester.startPage(page); + + tester.executeUrl(urlInMarkup("srcset", "src")); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, tester.getLastResponse().getStatus(), + "the request should have been refused"); + assertFalse(page.setEntry.responded, "the refused srcset entry streamed"); + } + + /** + * Control. + */ + @Test + void allowedSrcSetOfAnImageIsStillServed() + { + ImagePage page = new ImagePage(); + deny(page.src); + tester.startPage(page); + + tester.executeUrl(urlInMarkup("srcset", "src")); + + assertEquals(HttpServletResponse.SC_OK, tester.getLastResponse().getStatus()); + assertTrue(page.setSrc.responded, "the src of the image did not stream"); + assertTrue(page.setEntry.responded, "the srcset entry of the image did not stream"); + } + + @Test + void theStrategyIsAskedAboutEverySrcSetEntry() + { + ImagePage page = new ImagePage(); + deny(); + tester.startPage(page); + + tester.executeUrl(urlInMarkup("srcset", "src")); + + assertEquals(Arrays.asList(page.setSrc, page.setEntry), asked, + "the strategy must be asked about the src and about every srcset entry"); + } + + @Test + void imageButtonWithDeniedResourceIsRefused() + { + ImageButtonPage page = new ImageButtonPage(); + deny(page.denied); + tester.startPage(page); + + tester.executeUrl(urlInMarkup("denied", "src")); + + assertRefused(page.denied); + assertRefusalMessage(page.denied); + } + + /** + * Control. + */ + @Test + void imageButtonWithAllowedResourceIsStillServed() + { + ImageButtonPage page = new ImageButtonPage(); + deny(page.denied); + tester.startPage(page); + + tester.executeUrl(urlInMarkup("allowed", "src")); + + assertStreamed("buttonAllowed", page.allowed); + } + + @Test + void theStrategyIsAskedAboutTheInstanceTheComponentHoldsWithEmptyParameters() + { + ResourceLinkPage page = new ResourceLinkPage(); + deny(); + tester.startPage(page); + + tester.clickLink("allowed", false); + + assertEquals(Arrays.asList(page.allowed), asked); + assertNotNull(askedWith, "the strategy must not be asked with null parameters"); + assertTrue(askedWith.isEmpty(), "a listener URL carries no resource parameters"); + } + + @Test + void theUnauthorizedResourceRequestListenerOfTheApplicationIsUsed() + { + ResourceLinkPage page = new ResourceLinkPage(); + deny(page.denied); + tester.getApplication() + .getSecuritySettings() + .setUnauthorizedResourceRequestListener((resource, parameters) -> { + throw new IllegalStateException("refused " + resource); + }); + tester.startPage(page); + + RuntimeException e = assertThrows(RuntimeException.class, + () -> tester.clickLink("denied", false)); + + assertEquals("refused denied", + Exceptions.findCause(e, IllegalStateException.class).getMessage()); + assertFalse(page.denied.responded, + "the resource streamed although the listener refused the request"); + } + + /** A stateful page carrying both constructors of {@link ResourceLink}. */ + public static class ResourceLinkPage extends WebPage implements IMarkupResourceStreamProvider + { + private static final long serialVersionUID = 1L; + + final RecordingResource denied = new RecordingResource("denied"); + + final RecordingResource allowed = new RecordingResource("allowed"); + + final RecordingResource referenced = new RecordingResource("referenced"); + + boolean deniedClicked; + + ResourceLinkPage() + { + setStatelessHint(false); + + add(new ResourceLink<Void>("denied", denied) + { + private static final long serialVersionUID = 1L; + + @Override + public void onClick() + { + deniedClicked = true; + } + }); + add(new ResourceLink<Void>("allowed", allowed)); + add(new ResourceLink<Void>("reference", + new ResourceReference(ResourceLinkPage.class, "referenced") + { + private static final long serialVersionUID = 1L; + + @Override + public IResource getResource() + { + return referenced; + } + })); + } + + @Override + public IResourceStream getMarkupResourceStream(MarkupContainer container, + Class<?> containerClass) + { + return new StringResourceStream("<html><body>" // + + "<a wicket:id=\"denied\">denied</a>" // + + "<a wicket:id=\"allowed\">allowed</a>" // + + "<a wicket:id=\"reference\">reference</a>" // + + "</body></html>"); + } + } + + /** A stateful page whose images all hold an {@link IResource} rather than a reference. */ + public static class ImagePage extends WebPage implements IMarkupResourceStreamProvider + { + private static final long serialVersionUID = 1L; + + final RecordingResource src = new RecordingResource("src"); + + final RecordingResource setSrc = new RecordingResource("setSrc"); + + final RecordingResource setEntry = new RecordingResource("setEntry"); + + final RecordingResource fromModel = new RecordingResource("fromModel"); + + ImagePage() + { + setStatelessHint(false); + + add(new Image("src", src)); + add(new Image("srcset", setSrc, setEntry)); + add(new Image("model", Model.of(fromModel))); + } + + @Override + public IResourceStream getMarkupResourceStream(MarkupContainer container, + Class<?> containerClass) + { + return new StringResourceStream("<html><body>" // + + "<img wicket:id=\"src\"/>" // + + "<img wicket:id=\"srcset\"/>" // + + "<img wicket:id=\"model\"/>" // + + "</body></html>"); + } + } + + /** A stateful page carrying two image buttons, each holding an {@link IResource}. */ + public static class ImageButtonPage extends WebPage implements IMarkupResourceStreamProvider + { + private static final long serialVersionUID = 1L; + + final RecordingResource denied = new RecordingResource("buttonDenied"); + + final RecordingResource allowed = new RecordingResource("buttonAllowed"); + + ImageButtonPage() + { + setStatelessHint(false); + + Form<Void> form = new Form<>("form"); + add(form); + form.add(new ImageButton("denied", denied)); + form.add(new ImageButton("allowed", allowed)); + } + + @Override + public IResourceStream getMarkupResourceStream(MarkupContainer container, + Class<?> containerClass) + { + return new StringResourceStream("<html><body>" // + + "<form wicket:id=\"form\">" // + + "<input type=\"image\" wicket:id=\"denied\"/>" // + + "<input type=\"image\" wicket:id=\"allowed\"/>" // + + "</form></body></html>"); + } + } + + /** Records that it was asked to respond, and writes something recognisable when it does. */ + private static class RecordingResource implements IResource + { + private static final long serialVersionUID = 1L; + + private final String name; + + private boolean responded; + + RecordingResource(String name) + { + this.name = name; + } + + @Override + public void respond(Attributes attributes) + { + responded = true; + attributes.getResponse().write(name); + } + + @Override + public String toString() + { + return name; + } + } +} diff --git a/wicket-core/src/main/java/org/apache/wicket/authorization/IAuthorizationStrategy.java b/wicket-core/src/main/java/org/apache/wicket/authorization/IAuthorizationStrategy.java index 6e37f0f2ac..11159fe98c 100644 --- a/wicket-core/src/main/java/org/apache/wicket/authorization/IAuthorizationStrategy.java +++ b/wicket-core/src/main/java/org/apache/wicket/authorization/IAuthorizationStrategy.java @@ -106,11 +106,25 @@ public interface IAuthorizationStrategy /** * Checks whether a request with some parameters is allowed to a resource. + * <p> + * This is asked for every request that serves an {@link IResource}: one requested by the URL of + * a mounted or shared {@link org.apache.wicket.request.resource.ResourceReference}, and one + * held by a component or a behavior and served from the listener URL of that component - a + * {@link org.apache.wicket.markup.html.link.ResourceLink}, an + * {@link org.apache.wicket.markup.html.image.Image}, an + * {@link org.apache.wicket.markup.html.form.ImageButton}, or the AjaxDownloadBehavior of + * wicket-extensions. + * <p> + * Returning {@code false} hands the request to the + * {@link IUnauthorizedResourceRequestListener} of the security settings, which decides how it + * is refused; the default one answers 403. * * @param resource * The resource that should be processed * @param parameters - * The request parameters + * The request parameters, never {@code null}; they are empty for a resource served + * through the component that holds it, because a listener URL carries no resource + * parameters * @return {@code true} if the request to this resource is allowed. */ boolean isResourceAuthorized(IResource resource, PageParameters parameters); diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/image/resource/LocalizedImageResource.java b/wicket-core/src/main/java/org/apache/wicket/markup/html/image/resource/LocalizedImageResource.java index a76a22ef66..e3a14a47aa 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/image/resource/LocalizedImageResource.java +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/image/resource/LocalizedImageResource.java @@ -26,9 +26,9 @@ import org.apache.wicket.WicketRuntimeException; import org.apache.wicket.markup.ComponentTag; import org.apache.wicket.markup.html.border.Border; import org.apache.wicket.request.cycle.RequestCycle; +import org.apache.wicket.request.handler.resource.ResourceRequestHandler; import org.apache.wicket.request.mapper.parameter.PageParameters; import org.apache.wicket.request.resource.IResource; -import org.apache.wicket.request.resource.IResource.Attributes; import org.apache.wicket.request.resource.PackageResourceReference; import org.apache.wicket.request.resource.ResourceReference; import org.apache.wicket.util.io.IClusterable; @@ -188,16 +188,32 @@ public final class LocalizedImageResource implements IClusterable } /** + * Responds with the {@link IResource} this instance holds, once the + * {@link org.apache.wicket.authorization.IAuthorizationStrategy} has authorized it. + * <p> + * This is the only place where a resource served through the component that owns this instance + * is authorized. {@link #setSrcAttribute(ComponentTag)} renders the listener URL of that + * component whenever a resource - rather than a {@link ResourceReference} - is set, so the + * request is not carried by a + * {@link org.apache.wicket.request.handler.resource.ResourceReferenceRequestHandler} and is + * authorized here instead. Every caller arrives here: the src of an + * {@link org.apache.wicket.markup.html.image.Image}, each entry of its srcset, and the image of + * an {@link org.apache.wicket.markup.html.form.ImageButton}. + * <p> + * A resource the strategy refuses aborts the request through the + * {@link org.apache.wicket.authorization.IUnauthorizedResourceRequestListener} of the security + * settings, so a component that responds with several resources writes none of the ones that + * follow the refused one. + * * @param parameters - * page parameters + * page parameters; the callers within Wicket pass {@code null}, because a listener + * URL carries no resource parameters, and the strategy and the resource then see the + * empty parameters that {@link ResourceRequestHandler} substitutes */ public final void onResourceRequested(PageParameters parameters) { bind(); - RequestCycle requestCycle = RequestCycle.get(); - Attributes attributes = new Attributes(requestCycle.getRequest(), - requestCycle.getResponse(), parameters); - resource.respond(attributes); + new ResourceRequestHandler(resource, parameters).respond(RequestCycle.get()); } /** diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/link/ResourceLink.java b/wicket-core/src/main/java/org/apache/wicket/markup/html/link/ResourceLink.java index 303c06782c..1308bb1a4e 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/link/ResourceLink.java +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/link/ResourceLink.java @@ -17,11 +17,10 @@ package org.apache.wicket.markup.html.link; import org.apache.wicket.IRequestListener; -import org.apache.wicket.request.cycle.RequestCycle; import org.apache.wicket.request.handler.resource.ResourceReferenceRequestHandler; +import org.apache.wicket.request.handler.resource.ResourceRequestHandler; import org.apache.wicket.request.mapper.parameter.PageParameters; import org.apache.wicket.request.resource.IResource; -import org.apache.wicket.request.resource.IResource.Attributes; import org.apache.wicket.request.resource.ResourceReference; /** @@ -81,6 +80,10 @@ public class ResourceLink<T> extends Link<T> implements IRequestListener /** * Constructs a link directly to the provided resource. + * <p> + * Such a resource is served from the listener URL of this link rather than from a resource URL + * of its own, and it is the {@link #onRequest()} of this link that has the + * {@link org.apache.wicket.authorization.IAuthorizationStrategy} authorize it on every request. * * @param id * See Component @@ -146,13 +149,26 @@ public class ResourceLink<T> extends Link<T> implements IRequestListener return urlForListener(resourceParameters); } + /** + * Responds with the {@link IResource} this link was constructed with. + * <p> + * The resource is served through a {@link ResourceRequestHandler}, which asks the + * {@link org.apache.wicket.authorization.IAuthorizationStrategy} to authorize it, exactly as it + * is asked for a resource that is requested by its own URL. This is the only place where a + * resource held by this link is authorized, because {@link #getURL()} renders the listener URL + * of this link for such a resource rather than a resource URL, so the request never reaches a + * {@link ResourceReferenceRequestHandler}. + * <p> + * A resource the strategy refuses aborts the request - the + * {@link org.apache.wicket.authorization.IUnauthorizedResourceRequestListener} of the security + * settings decides how, the default one answers 403 - so {@link #onClick()} is not reached + * either. This method is final, so a subclass cannot serve the resource without that decision. + */ @Override public final void onRequest() { - Attributes a = new Attributes(RequestCycle.get().getRequest(), RequestCycle.get() - .getResponse(), null); - resource.respond(a); - + new ResourceRequestHandler(resource, resourceParameters).respond(getRequestCycle()); + super.onRequest(); - } + } } diff --git a/wicket-core/src/main/java/org/apache/wicket/request/resource/IResource.java b/wicket-core/src/main/java/org/apache/wicket/request/resource/IResource.java index 82078d154f..32d40305dd 100644 --- a/wicket-core/src/main/java/org/apache/wicket/request/resource/IResource.java +++ b/wicket-core/src/main/java/org/apache/wicket/request/resource/IResource.java @@ -33,7 +33,9 @@ public interface IResource extends IClusterable { /** * Attributes that are provided to resource in the {@link IResource#respond(Attributes)} method. - * Attributes are set by the {@link ResourceRequestHandler}. + * Attributes are set by the {@link ResourceRequestHandler}. Constructing them in application + * code in order to call {@link IResource#respond(Attributes)} serves the resource without asking + * whether it may be served - see that method. * * @author Matej Knopp */ @@ -109,6 +111,12 @@ public interface IResource extends IClusterable /** * Renders this resource to response using the provided attributes. + * <p> + * <b>The resource is written to the response as is, without asking whether it may be served.</b> + * The {@link org.apache.wicket.authorization.IAuthorizationStrategy} is asked in the constructor + * of {@link ResourceRequestHandler} and nowhere else, so application code that serves a resource + * must construct that handler for it and respond or schedule it, rather than call this method. + * </p> * * @param attributes */ diff --git a/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/AjaxDownloadBehavior.java b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/AjaxDownloadBehavior.java index 1397db3eec..76024e16e8 100644 --- a/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/AjaxDownloadBehavior.java +++ b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/AjaxDownloadBehavior.java @@ -31,6 +31,7 @@ import org.apache.wicket.markup.head.JavaScriptHeaderItem; import org.apache.wicket.request.Response; import org.apache.wicket.request.cycle.RequestCycle; import org.apache.wicket.request.handler.resource.ResourceReferenceRequestHandler; +import org.apache.wicket.request.handler.resource.ResourceRequestHandler; import org.apache.wicket.request.http.WebResponse; import org.apache.wicket.request.mapper.parameter.PageParameters; import org.apache.wicket.request.resource.ContentDisposition; @@ -132,6 +133,11 @@ public class AjaxDownloadBehavior extends AbstractDefaultAjaxBehavior /** * Download of a {@link IResource}. + * <p> + * The resource is served from the listener URL of the component this behavior is bound to + * rather than from a resource URL of its own, and the + * {@link org.apache.wicket.authorization.IAuthorizationStrategy} authorizes it on every + * request, exactly as it does for a {@link ResourceReference}. * * @param resource * resource to download @@ -371,16 +377,28 @@ public class AjaxDownloadBehavior extends AbstractDefaultAjaxBehavior return false; } + /** + * Responds with the resource, once the + * {@link org.apache.wicket.authorization.IAuthorizationStrategy} has authorized it. + * <p> + * The handler is created before the cookie is added, because it is its constructor that + * asks the strategy. That cookie is what tells wicket-ajaxdownload.js that the download + * finished, it is looked at there before the response body is, and a buffered response + * replays a cookie ahead of the error the strategy causes - so a refused download must not + * leave one behind, or it would be reported as + * {@link AjaxDownloadBehavior#onDownloadSuccess(AjaxRequestTarget)}. + */ @Override public void onRequest() { final RequestCycle requestCycle = RequestCycle.get(); final Response response = requestCycle.getResponse(); - ((WebResponse) response).addCookie(cookie(getName(), sameSite)); - Attributes a = new Attributes(requestCycle.getRequest(), response, null); + ResourceRequestHandler handler = new ResourceRequestHandler(resource, null); + + ((WebResponse) response).addCookie(cookie(getName(), sameSite)); - resource.respond(a); + handler.respond(requestCycle); } public CharSequence getUrl() diff --git a/wicket-extensions/src/test/java/org/apache/wicket/extensions/ajax/AjaxDownloadResourceAuthorizationTest.java b/wicket-extensions/src/test/java/org/apache/wicket/extensions/ajax/AjaxDownloadResourceAuthorizationTest.java new file mode 100644 index 0000000000..4d1a94f233 --- /dev/null +++ b/wicket-extensions/src/test/java/org/apache/wicket/extensions/ajax/AjaxDownloadResourceAuthorizationTest.java @@ -0,0 +1,265 @@ +/* + * 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.extensions.ajax; + +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.assertTrue; + +import java.util.Arrays; +import java.util.List; + +import jakarta.servlet.http.HttpServletResponse; + +import org.apache.wicket.Component; +import org.apache.wicket.MarkupContainer; +import org.apache.wicket.Page; +import org.apache.wicket.authorization.IAuthorizationStrategy; +import org.apache.wicket.behavior.Behavior; +import org.apache.wicket.core.request.handler.ListenerRequestHandler; +import org.apache.wicket.core.request.handler.PageAndComponentProvider; +import org.apache.wicket.markup.IMarkupResourceStreamProvider; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.markup.html.WebPage; +import org.apache.wicket.request.IRequestHandler; +import org.apache.wicket.request.mapper.parameter.PageParameters; +import org.apache.wicket.request.resource.IResource; +import org.apache.wicket.request.resource.ResourceReference; +import org.apache.wicket.util.resource.IResourceStream; +import org.apache.wicket.util.resource.StringResourceStream; +import org.apache.wicket.util.tester.WicketTestCase; +import org.junit.jupiter.api.Test; + +/** + * An {@link AjaxDownloadBehavior} constructed from an {@link IResource} serves it from the listener + * URL of the component it is bound to, so the {@link IAuthorizationStrategy} has to authorize it + * there just as it does for the {@link ResourceReference} the other constructor takes. + */ +class AjaxDownloadResourceAuthorizationTest extends WicketTestCase +{ + private void deny(final IResource... denied) + { + final List<IResource> refused = Arrays.asList(denied); + + tester.getApplication() + .getSecuritySettings() + .setAuthorizationStrategy(new IAuthorizationStrategy.AllowAllAuthorizationStrategy() + { + @Override + public boolean isResourceAuthorized(IResource resource, PageParameters parameters) + { + return refused.contains(resource) == false; + } + }); + } + + /** + * The URL a behavior constructed from an {@link IResource} hands to the browser when it + * initiates: the listener URL of the behavior the download added to serve that resource. That + * behavior is private, so it can only be recognised as the one behavior of the component that + * is not the download itself. + */ + private String downloadUrl(Component component, AjaxDownloadBehavior download) + { + Page page = component.getPage(); + assertFalse(page.isPageStateless(), "the fixture page must be stateful"); + + Behavior resourceBehavior = null; + for (Behavior behavior : component.getBehaviors()) + { + if (behavior != download) + { + resourceBehavior = behavior; + } + } + assertNotNull(resourceBehavior, + "the download did not add a behavior to serve its resource"); + + IRequestHandler handler = new ListenerRequestHandler( + new PageAndComponentProvider(page, component), + component.getBehaviorId(resourceBehavior)); + + String url = tester.urlFor(handler).toString(); + assertTrue(url.contains("?" + page.getPageId() + "-"), + "expected a listener URL carrying the page identifier, was " + url); + + return url; + } + + @Test + void downloadOfADeniedResourceIsRefused() + { + DownloadPage page = new DownloadPage(); + deny(page.resource); + tester.startPage(page); + + tester.executeUrl(downloadUrl(page.resourceContainer, page.resourceDownload)); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, tester.getLastResponse().getStatus(), + "the download should have been refused"); + assertEquals( + "The request to resource 'streamedDownload' with parameters '' cannot be authorized.", + tester.getLastResponse().getErrorMessage()); + assertFalse(page.resource.responded, + "the resource streamed although the strategy refused it"); + assertFalse(tester.getLastResponseAsString().contains("streamedDownload"), + "the content of the resource reached the response"); + } + + /** + * Control: the fix must not refuse a download the strategy allows. + */ + @Test + void downloadOfAnAllowedResourceIsStillServed() + { + DownloadPage page = new DownloadPage(); + deny(page.referenced); + tester.startPage(page); + + tester.executeUrl(downloadUrl(page.resourceContainer, page.resourceDownload)); + + assertEquals(HttpServletResponse.SC_OK, tester.getLastResponse().getStatus()); + assertTrue(page.resource.responded, "the resource did not stream"); + assertEquals("streamedDownload", tester.getLastResponseAsString()); + } + + /** + * The {@link ResourceReference} constructor serves its resource from a resource URL instead, so + * it adds no behavior of its own - which is why that route was authorized all along. + */ + @Test + void theResourceReferenceVariantAddsNoBehaviorOfItsOwn() + { + DownloadPage page = new DownloadPage(); + tester.startPage(page); + + assertEquals(1, page.referenceContainer.getBehaviors().size(), + "the ResourceReference variant must not add a behavior that serves the resource"); + } + + @Test + void downloadOfADeniedResourceReferenceIsStillRefused() + { + DownloadPage page = new DownloadPage(); + deny(page.referenced); + tester.startPage(page); + + tester.startResourceReference(page.reference); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, tester.getLastResponse().getStatus()); + assertFalse(page.referenced.responded, + "the referenced resource streamed although the strategy refused it"); + } + + /** + * Control. + */ + @Test + void downloadOfAnAllowedResourceReferenceIsStillServed() + { + DownloadPage page = new DownloadPage(); + deny(page.resource); + tester.startPage(page); + + tester.startResourceReference(page.reference); + + assertEquals(HttpServletResponse.SC_OK, tester.getLastResponse().getStatus()); + assertTrue(page.referenced.responded, "the referenced resource did not stream"); + } + + /** A stateful page carrying both constructors of {@link AjaxDownloadBehavior}. */ + public static class DownloadPage extends WebPage implements IMarkupResourceStreamProvider + { + private static final long serialVersionUID = 1L; + + final RecordingResource resource = new RecordingResource("streamedDownload"); + + final RecordingResource referenced = new RecordingResource("referenced"); + + final ResourceReference reference; + + final WebMarkupContainer resourceContainer; + + final WebMarkupContainer referenceContainer; + + final AjaxDownloadBehavior resourceDownload; + + DownloadPage() + { + setStatelessHint(false); + + reference = new ResourceReference(DownloadPage.class, "referenced") + { + private static final long serialVersionUID = 1L; + + @Override + public IResource getResource() + { + return referenced; + } + }; + + resourceDownload = new AjaxDownloadBehavior(resource); + resourceContainer = new WebMarkupContainer("resource"); + resourceContainer.add(resourceDownload); + add(resourceContainer); + + referenceContainer = new WebMarkupContainer("reference"); + referenceContainer.add(new AjaxDownloadBehavior(reference)); + add(referenceContainer); + } + + @Override + public IResourceStream getMarkupResourceStream(MarkupContainer container, + Class<?> containerClass) + { + return new StringResourceStream("<html><body>" // + + "<span wicket:id=\"resource\">resource</span>" // + + "<span wicket:id=\"reference\">reference</span>" // + + "</body></html>"); + } + } + + /** Records that it was asked to respond, and writes something recognisable when it does. */ + private static class RecordingResource implements IResource + { + private static final long serialVersionUID = 1L; + + private final String name; + + private boolean responded; + + RecordingResource(String name) + { + this.name = name; + } + + @Override + public void respond(Attributes attributes) + { + responded = true; + attributes.getResponse().write(name); + } + + @Override + public String toString() + { + return name; + } + } +} diff --git a/wicket-extensions/src/test/java/org/apache/wicket/extensions/markup/html/repeater/data/table/export/ExportToolbarResourceAuthorizationTest.java b/wicket-extensions/src/test/java/org/apache/wicket/extensions/markup/html/repeater/data/table/export/ExportToolbarResourceAuthorizationTest.java new file mode 100644 index 0000000000..3425813233 --- /dev/null +++ b/wicket-extensions/src/test/java/org/apache/wicket/extensions/markup/html/repeater/data/table/export/ExportToolbarResourceAuthorizationTest.java @@ -0,0 +1,155 @@ +/* + * 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.extensions.markup.html.repeater.data.table.export; + +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.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import jakarta.servlet.http.HttpServletResponse; + +import org.apache.wicket.MarkupContainer; +import org.apache.wicket.authorization.IAuthorizationStrategy; +import org.apache.wicket.extensions.markup.html.repeater.data.table.Contact; +import org.apache.wicket.extensions.markup.html.repeater.data.table.DataTable; +import org.apache.wicket.extensions.markup.html.repeater.data.table.IColumn; +import org.apache.wicket.extensions.markup.html.repeater.data.table.PropertyColumn; +import org.apache.wicket.markup.IMarkupResourceStreamProvider; +import org.apache.wicket.markup.html.WebPage; +import org.apache.wicket.markup.html.link.ResourceLink; +import org.apache.wicket.markup.repeater.data.ListDataProvider; +import org.apache.wicket.model.Model; +import org.apache.wicket.request.mapper.parameter.PageParameters; +import org.apache.wicket.request.resource.IResource; +import org.apache.wicket.request.resource.ResourceStreamResource; +import org.apache.wicket.util.resource.IResourceStream; +import org.apache.wicket.util.resource.StringResourceStream; +import org.apache.wicket.util.tester.WicketTestCase; +import org.apache.wicket.util.visit.IVisit; +import org.apache.wicket.util.visit.IVisitor; +import org.junit.jupiter.api.Test; + +/** + * {@link ExportToolbar} builds each of its export links from an {@link IResource} of its own, so + * the exported contents of a data table are subject to the {@link IAuthorizationStrategy} like any + * other resource a component holds. + */ +class ExportToolbarResourceAuthorizationTest extends WicketTestCase +{ + /** Every resource the strategy was asked about, in the order it was asked. */ + private final List<IResource> asked = new ArrayList<>(); + + private void allowEverything(final boolean allowed) + { + tester.getApplication() + .getSecuritySettings() + .setAuthorizationStrategy(new IAuthorizationStrategy.AllowAllAuthorizationStrategy() + { + @Override + public boolean isResourceAuthorized(IResource resource, PageParameters parameters) + { + asked.add(resource); + return allowed; + } + }); + } + + /** + * The toolbar creates its resource and its link itself, so neither can be named from here. + */ + private ResourceLink<?> exportLink() + { + ResourceLink<?> link = tester.getLastRenderedPage() + .visitChildren(ResourceLink.class, new IVisitor<ResourceLink<?>, ResourceLink<?>>() + { + @Override + public void component(ResourceLink<?> link, IVisit<ResourceLink<?>> visit) + { + visit.stop(link); + } + }); + + assertNotNull(link, "the export toolbar did not render an export link"); + return link; + } + + @Test + void exportIsRefusedWhenTheStrategyRefusesTheResource() + { + tester.startPage(new ExportPage()); + allowEverything(false); + + tester.clickLink(exportLink().getPageRelativePath(), false); + + assertEquals(HttpServletResponse.SC_FORBIDDEN, tester.getLastResponse().getStatus(), + "the export should have been refused"); + assertFalse(tester.getLastResponseAsString().contains("clandestine"), + "the exported data reached the response although the strategy refused it"); + assertEquals(1, asked.size(), "the strategy should have been asked once"); + assertTrue(asked.get(0) instanceof ResourceStreamResource, + "expected the resource the toolbar builds over the exported data, was " + asked.get(0)); + } + + /** + * Control: the fix must not refuse an export the strategy allows. + */ + @Test + void exportIsStillServedWhenTheStrategyAllowsTheResource() + { + tester.startPage(new ExportPage()); + allowEverything(true); + + tester.clickLink(exportLink().getPageRelativePath(), false); + + assertEquals(HttpServletResponse.SC_OK, tester.getLastResponse().getStatus()); + assertTrue(tester.getLastResponseAsString().contains("clandestine"), + "the export did not contain the data of the table"); + } + + /** A table of one row with one exportable column, and a toolbar that exports it. */ + public static class ExportPage extends WebPage implements IMarkupResourceStreamProvider + { + private static final long serialVersionUID = 1L; + + ExportPage() + { + setStatelessHint(false); + + List<IColumn<Contact, String>> columns = new ArrayList<>(); + columns.add(new PropertyColumn<Contact, String>(Model.of("firstName"), "firstName")); + + DataTable<Contact, String> table = new DataTable<>("table", columns, + new ListDataProvider<>(Arrays.asList(new Contact("clandestine", "row"))), 10); + table.addBottomToolbar(new ExportToolbar(table, Model.of("Export to"), + Model.of("export")).addDataExporter(new CSVDataExporter())); + add(table); + } + + @Override + public IResourceStream getMarkupResourceStream(MarkupContainer container, + Class<?> containerClass) + { + return new StringResourceStream( + "<html><body><table wicket:id=\"table\"></table></body></html>"); + } + } +}
