Skip to content

Commit 28e1cc9

Browse files
authored
fix!: auto-apply context:// for @Stylesheet values (#24235)
@Stylesheet currently produces broken <link> elements when the Vaadin servlet is mapped at a non-root path (vaadin.urlMapping=/ui/* etc.): the browser resolves the bare relative href against <base> (which points to the servlet mapping path), so a file at META-INF/resources/styles.css is fetched as /ui/styles.css and 404s. Users had to know to write @Stylesheet("context://styles.css") explicitly to step out of the servlet path. Frame the right behavior into the framework instead: - New FrontendDependencyUrlResolver.resolveToContextRoot extracts the prefix-handling rules into one place: http(s)://, //, context://, base:// pass through unchanged; "/" is treated as an absolute server path; "./" leads strip to a context-root-relative value; everything else gets a context:// prefix prepended. Path traversals are rejected. - UIInternals.addComponentDependencies normalizes @Stylesheet values through the resolver before storing them on the dependency list, so component-level @Stylesheet now renders correctly under any servlet mapping. The same normalization keys ActiveStyleSheetTracker so spelling variants of the same file (foo.css, ./foo.css, context://foo.css) deduplicate to a single <link>. - AppShellRegistry.resolveStyleSheetHref delegates to the same resolver, replacing the inline rule set. The trailing BootstrapUriResolver call continues to expand context:// using the servlet-relative path produced by getContextRootRelativePath, so AppShell-level resolution stays consistent with the UIDL path. Test fixtures updated for the canonical context://-prefixed URLs in the dependency list. UidlWriterTest also registers inline test resources at the leading-slash path (/inline.css) to match the servlet container lookup that resolveResource produces for a context:// value. Fixes #22888
1 parent 805b3f5 commit 28e1cc9

8 files changed

Lines changed: 314 additions & 90 deletions

File tree

‎flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
import com.vaadin.flow.router.internal.BeforeEnterHandler;
8787
import com.vaadin.flow.router.internal.BeforeLeaveHandler;
8888
import com.vaadin.flow.server.Command;
89+
import com.vaadin.flow.server.FrontendDependencyUrlResolver;
8990
import com.vaadin.flow.server.VaadinService;
9091
import com.vaadin.flow.server.VaadinSession;
9192
import com.vaadin.flow.server.communication.PushConnection;
@@ -1092,14 +1093,24 @@ public void addComponentDependencies(
10921093
triggerChunkLoading(componentClass);
10931094
}
10941095

1095-
dependencies.getStyleSheets().forEach(styleSheet -> page
1096-
.addStyleSheet(styleSheet.value(), styleSheet.loadMode()));
1096+
dependencies.getStyleSheets().forEach(styleSheet -> {
1097+
String resolved = FrontendDependencyUrlResolver
1098+
.resolveToContextRoot(styleSheet.value());
1099+
if (resolved != null) {
1100+
page.addStyleSheet(resolved, styleSheet.loadMode());
1101+
}
1102+
});
10971103

10981104
VaadinService service = session.getService();
10991105
if (!service.getDeploymentConfiguration().isProductionMode()) {
1100-
dependencies.getStyleSheets()
1101-
.forEach(styleSheet -> ActiveStyleSheetTracker.get(service)
1102-
.trackAddForComponent(styleSheet.value()));
1106+
dependencies.getStyleSheets().forEach(styleSheet -> {
1107+
String resolved = FrontendDependencyUrlResolver
1108+
.resolveToContextRoot(styleSheet.value());
1109+
if (resolved != null) {
1110+
ActiveStyleSheetTracker.get(service)
1111+
.trackAddForComponent(resolved);
1112+
}
1113+
});
11031114
}
11041115

11051116
warnForUnavailableBundledDependencies(componentClass, dependencies);

‎flow-server/src/main/java/com/vaadin/flow/server/AppShellRegistry.java‎

Lines changed: 7 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -280,48 +280,22 @@ public class Application implements AppShellConfigurator {
280280

281281
private static String resolveStyleSheetHref(String href,
282282
VaadinRequest request) {
283-
if (href == null || href.isBlank()) {
283+
String normalized = FrontendDependencyUrlResolver
284+
.resolveToContextRoot(href);
285+
if (normalized == null) {
284286
return null;
285287
}
286-
if (HandlerHelper
287-
.isPathUnsafe(href.startsWith("/") ? href : "/" + href)) {
288-
log.warn(
289-
"@StyleSheet href containing traversals ('../') are not allowed, ignored: {}",
290-
href);
291-
return null;
292-
}
293-
href = href.trim();
294-
// Accept absolute http(s) URLs unchanged
295-
String lower = href.toLowerCase();
296-
if (lower.startsWith("http://") || lower.startsWith("https://")) {
297-
return href;
298-
}
299-
// Treat ./ as relative path to static resources location
300-
if (href.startsWith("./")) {
301-
href = href.substring(2);
302-
}
303-
// Accept bare paths beginning with '/' as-is
304-
if (href.startsWith("/")) {
305-
return href;
306-
}
307-
308-
String contextProtocol = ApplicationConstants.CONTEXT_PROTOCOL_PREFIX;
309-
if (!lower.startsWith(contextProtocol)) {
310-
// Prepend context protocol so URL is resolved against the
311-
// context root by the bootstrap URI resolver below.
312-
href = contextProtocol + href;
313-
}
314288
// Use the servlet-relative path (e.g. "./", "../") rather than the
315289
// absolute context path. The emitted href is then resolved by the
316290
// browser against <base>, which Vaadin sets from the actual request
317-
// URL (honoring X-Forwarded-* headers). This works correctly behind
318-
// reverse proxies that rewrite or strip the context path in the
319-
// public URL.
291+
// URL (honoring X-Forwarded-* headers). This matches how the
292+
// bootstrap CONTEXT_ROOT_URL is computed for the UIDL path and works
293+
// correctly behind reverse proxies that rewrite the public path.
320294
String servletPathToContextRoot = request.getService()
321295
.getContextRootRelativePath(request);
322296
BootstrapHandler.BootstrapUriResolver resolver = new BootstrapHandler.BootstrapUriResolver(
323297
servletPathToContextRoot, null);
324-
return resolver.resolveVaadinUri(href);
298+
return resolver.resolveVaadinUri(normalized);
325299
}
326300

327301
/**
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.server;
17+
18+
import java.io.Serializable;
19+
20+
import org.slf4j.Logger;
21+
import org.slf4j.LoggerFactory;
22+
23+
import com.vaadin.flow.shared.ApplicationConstants;
24+
25+
/**
26+
* Resolves the {@code value()} of
27+
* {@link com.vaadin.flow.component.dependency.StyleSheet} annotation values to
28+
* a canonical form that
29+
* {@link com.vaadin.flow.server.BootstrapHandler.BootstrapUriResolver} can
30+
* expand at render time. The same rules are used whether the annotation is on
31+
* an {@link com.vaadin.flow.component.page.AppShellConfigurator} or on an
32+
* ordinary {@link com.vaadin.flow.component.Component}.
33+
* <p>
34+
* For internal framework use only.
35+
*/
36+
public final class FrontendDependencyUrlResolver implements Serializable {
37+
38+
private static final Logger LOGGER = LoggerFactory
39+
.getLogger(FrontendDependencyUrlResolver.class);
40+
41+
private FrontendDependencyUrlResolver() {
42+
}
43+
44+
/**
45+
* Normalizes a frontend-dependency URL value to a form that the bootstrap
46+
* URI resolver can expand.
47+
* <p>
48+
* Rules, in order:
49+
* <ol>
50+
* <li>{@code null} or blank values return {@code null}.</li>
51+
* <li>Values containing path traversals ({@code ..}) are rejected with a
52+
* warning and {@code null} is returned.</li>
53+
* <li>{@code http://}, {@code https://}, {@code //}, {@code context://} and
54+
* {@code base://} prefixes are returned unchanged.</li>
55+
* <li>A leading {@code ./} is stripped.</li>
56+
* <li>If the value (after the previous step) starts with {@code /}, it is
57+
* returned unchanged.</li>
58+
* <li>Otherwise, {@code context://} is prepended so the value resolves
59+
* against the servlet context root.</li>
60+
* </ol>
61+
*
62+
* @param rawValue
63+
* the raw annotation value
64+
* @return the normalized value, or {@code null} if rejected
65+
*/
66+
public static String resolveToContextRoot(String rawValue) {
67+
if (rawValue == null || rawValue.isBlank()) {
68+
return null;
69+
}
70+
String value = rawValue.trim();
71+
String pathForCheck = value.startsWith("/") ? value : "/" + value;
72+
if (HandlerHelper.isPathUnsafe(pathForCheck)) {
73+
LOGGER.warn(
74+
"Frontend dependency value containing traversals ('../') is not allowed, ignored: {}",
75+
value);
76+
return null;
77+
}
78+
String lower = value.toLowerCase();
79+
if (lower.startsWith("http://") || lower.startsWith("https://")
80+
|| lower.startsWith("//")
81+
|| lower.startsWith(
82+
ApplicationConstants.CONTEXT_PROTOCOL_PREFIX)
83+
|| lower.startsWith(
84+
ApplicationConstants.BASE_PROTOCOL_PREFIX)) {
85+
return value;
86+
}
87+
if (value.startsWith("./")) {
88+
value = value.substring(2);
89+
}
90+
if (value.startsWith("/")) {
91+
return value;
92+
}
93+
return ApplicationConstants.CONTEXT_PROTOCOL_PREFIX + value;
94+
}
95+
}

‎flow-server/src/test/java/com/vaadin/flow/component/ComponentTest.java‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,7 +1322,7 @@ public void usesComponent() {
13221322
ui.getInternals().getDependencyList().getPendingSendToClient());
13231323
assertEquals(1, pendingDependencies.size());
13241324

1325-
assertDependency(Dependency.Type.STYLESHEET, "css.css",
1325+
assertDependency(Dependency.Type.STYLESHEET, "context://css.css",
13261326
pendingDependencies);
13271327
}
13281328

@@ -1337,7 +1337,7 @@ public void usesChain() {
13371337
internals.getDependencyList().getPendingSendToClient());
13381338
assertEquals(1, pendingDependencies.size());
13391339

1340-
assertDependency(Dependency.Type.STYLESHEET, "css.css",
1340+
assertDependency(Dependency.Type.STYLESHEET, "context://css.css",
13411341
pendingDependencies);
13421342
}
13431343

@@ -1351,9 +1351,9 @@ public void circularDependencies() {
13511351
dependencyList.getPendingSendToClient());
13521352
assertEquals(2, pendingDependencies.size());
13531353

1354-
assertDependency(Dependency.Type.STYLESHEET, "css1.css",
1354+
assertDependency(Dependency.Type.STYLESHEET, "context://css1.css",
13551355
pendingDependencies);
1356-
assertDependency(Dependency.Type.STYLESHEET, "css2.css",
1356+
assertDependency(Dependency.Type.STYLESHEET, "context://css2.css",
13571357
pendingDependencies);
13581358

13591359
internals = new MockUI().getInternals();
@@ -1362,9 +1362,9 @@ public void circularDependencies() {
13621362
pendingDependencies = getDependenciesMap(
13631363
dependencyList.getPendingSendToClient());
13641364
assertEquals(2, pendingDependencies.size());
1365-
assertDependency(Dependency.Type.STYLESHEET, "css1.css",
1365+
assertDependency(Dependency.Type.STYLESHEET, "context://css1.css",
13661366
pendingDependencies);
1367-
assertDependency(Dependency.Type.STYLESHEET, "css2.css",
1367+
assertDependency(Dependency.Type.STYLESHEET, "context://css2.css",
13681368
pendingDependencies);
13691369

13701370
}
@@ -1386,7 +1386,7 @@ public void noJsDependenciesAreAdded() {
13861386
Map<String, Dependency> pendingDependencies = getDependenciesMap(
13871387
dependencyList.getPendingSendToClient());
13881388
assertEquals(1, pendingDependencies.size());
1389-
assertDependency(Dependency.Type.STYLESHEET, "css.css",
1389+
assertDependency(Dependency.Type.STYLESHEET, "context://css.css",
13901390
pendingDependencies);
13911391
}
13921392

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.server;
17+
18+
import org.junit.Assert;
19+
import org.junit.Test;
20+
21+
public class FrontendDependencyUrlResolverTest {
22+
23+
@Test
24+
public void resolveToContextRoot_nullOrBlank_returnsNull() {
25+
Assert.assertNull(
26+
FrontendDependencyUrlResolver.resolveToContextRoot(null));
27+
Assert.assertNull(
28+
FrontendDependencyUrlResolver.resolveToContextRoot(""));
29+
Assert.assertNull(
30+
FrontendDependencyUrlResolver.resolveToContextRoot(" "));
31+
}
32+
33+
@Test
34+
public void resolveToContextRoot_pathTraversal_returnsNull() {
35+
Assert.assertNull(FrontendDependencyUrlResolver
36+
.resolveToContextRoot("../foo.css"));
37+
Assert.assertNull(FrontendDependencyUrlResolver
38+
.resolveToContextRoot("foo/../bar.css"));
39+
}
40+
41+
@Test
42+
public void resolveToContextRoot_externalUrls_unchanged() {
43+
Assert.assertEquals("http://cdn/x.css", FrontendDependencyUrlResolver
44+
.resolveToContextRoot("http://cdn/x.css"));
45+
Assert.assertEquals("https://cdn/x.css", FrontendDependencyUrlResolver
46+
.resolveToContextRoot("https://cdn/x.css"));
47+
Assert.assertEquals("//cdn/x.css", FrontendDependencyUrlResolver
48+
.resolveToContextRoot("//cdn/x.css"));
49+
}
50+
51+
@Test
52+
public void resolveToContextRoot_explicitProtocols_unchanged() {
53+
Assert.assertEquals("context://foo.css", FrontendDependencyUrlResolver
54+
.resolveToContextRoot("context://foo.css"));
55+
Assert.assertEquals("base://foo.css", FrontendDependencyUrlResolver
56+
.resolveToContextRoot("base://foo.css"));
57+
}
58+
59+
@Test
60+
public void resolveToContextRoot_absoluteServerPath_unchanged() {
61+
Assert.assertEquals("/assets/foo.css", FrontendDependencyUrlResolver
62+
.resolveToContextRoot("/assets/foo.css"));
63+
}
64+
65+
@Test
66+
public void resolveToContextRoot_dotSlashRelative_strippedAndPrefixed() {
67+
Assert.assertEquals("context://foo.css", FrontendDependencyUrlResolver
68+
.resolveToContextRoot("./foo.css"));
69+
}
70+
71+
@Test
72+
public void resolveToContextRoot_bareRelative_prefixedWithContext() {
73+
Assert.assertEquals("context://foo.css",
74+
FrontendDependencyUrlResolver.resolveToContextRoot("foo.css"));
75+
Assert.assertEquals("context://styles/foo.css",
76+
FrontendDependencyUrlResolver
77+
.resolveToContextRoot("styles/foo.css"));
78+
}
79+
80+
@Test
81+
public void resolveToContextRoot_trimsWhitespace() {
82+
Assert.assertEquals("context://foo.css", FrontendDependencyUrlResolver
83+
.resolveToContextRoot(" foo.css "));
84+
}
85+
}

0 commit comments

Comments
 (0)