From ebef12d92cdd09b9ea33d8061e771e12ef553fb1 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 29 Sep 2026 19:11:51 +0200 Subject: [PATCH] Rewrite CssLinkResourceTransformer for efficient parsing Prior to this commit, both variants of `CssLinkResourceTransformer` would use a simple CSS parser for detecting CSS links and delegating to the transformer for links rewrite. This commit rewrites the internal parser for a single pass, byte by byte, memory efficient parsing. This implementation is also more resistant to edge cases. Closes gh-37348 --- .../web/reactive/resource/CssLinkParser.java | 322 ++++++++++++++++++ .../resource/CssLinkResourceTransformer.java | 258 +++----------- .../reactive/resource/CssLinkParserTests.java | 242 +++++++++++++ .../CssLinkResourceTransformerTests.java | 16 +- .../web/servlet/resource/CssLinkParser.java | 316 +++++++++++++++++ .../resource/CssLinkResourceTransformer.java | 211 ++---------- .../servlet/resource/CssLinkParserTests.java | 234 +++++++++++++ .../CssLinkResourceTransformerTests.java | 13 +- 8 files changed, 1195 insertions(+), 417 deletions(-) create mode 100644 spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkParser.java create mode 100644 spring-webflux/src/test/java/org/springframework/web/reactive/resource/CssLinkParserTests.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/resource/CssLinkParser.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/resource/CssLinkParserTests.java diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkParser.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkParser.java new file mode 100644 index 00000000000..a2c0a18a7aa --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkParser.java @@ -0,0 +1,322 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.web.reactive.resource; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.core.io.buffer.DataBuffer; + +/** + * A lightweight, forward-only parser that recognizes CSS {@code url()} + * and {@code @import} link tokens. + * + *

This parser is used by {@link CssLinkResourceTransformer} to rewrite + * CSS links on-the-fly, if needed. Other tokens are handed back as is + * to be written to the output stream. Invalid links (unterminated, containing + * characters not allowed by the CSS syntax, or exceeding {@link #MAX_LINK_LENGTH}) + * are skipped and written as-is, and parsing resumes right after them. + * + * @author Brian Clozel + * @since 7.1 + */ +final class CssLinkParser { + + private static final byte[] URL_KEYWORD = {'u', 'r', 'l', '('}; + + private static final byte[] IMPORT_KEYWORD = {'@', 'i', 'm', 'p', 'o', 'r', 't'}; + + private static final int MAX_LINK_LENGTH = 2048; + + + private State state = State.CONTENT; + + private byte[] keyword = URL_KEYWORD; + + private int matched; + + private byte terminator; + + /** Length of an unquoted link, excluding trailing whitespace, or -1 if none seen yet. */ + private int linkEnd = -1; + + /** Whether the previous byte was an identifier character. */ + private boolean previousWasIdentifierChar; + + private boolean hasLinks; + + private final ByteArrayOutputStream literal = new ByteArrayOutputStream(); + + private final ByteArrayOutputStream link = new ByteArrayOutputStream(); + + private List tokens = new ArrayList<>(); + + + /** + * Feed the next buffer of input to the parser, reading directly from it + * rather than copying it into a {@code byte[]} first. Does not affect the + * buffer's read position, and does not release it; that remains the + * caller's responsibility. + * @return the tokens produced by this buffer + */ + List feed(DataBuffer buffer) { + buffer.forEachByte(buffer.readPosition(), buffer.readableByteCount(), b -> { + processByte(b); + return true; + }); + return flushTokens(); + } + + /** + * Signal the end of the input. + * @return the remaining tokens + */ + List end() { + abortLink(); + return flushTokens(); + } + + /** + * Whether at least one link token was produced so far. + */ + boolean hasLinks() { + return this.hasLinks; + } + + private void processByte(byte b) { + boolean reexamine; + do { + reexamine = this.state.process(b, this); + } + while (reexamine); + this.previousWasIdentifierChar = State.isIdentifierChar(b); + } + + private void appendToLink(byte b) { + this.link.write(b); + // ignore links larger than the maximum length + if (this.link.size() > MAX_LINK_LENGTH) { + abortLink(); + } + } + + /** + * Emit the first {@code length} bytes of the current link as a link token, + * followed by any remaining bytes and the terminator as literal content. + */ + private void completeLink(int length, byte terminator) { + flushLiteral(); + byte[] bytes = this.link.toByteArray(); + if (length > 0) { + this.tokens.add(new Token((length < bytes.length ? Arrays.copyOf(bytes, length) : bytes), true)); + this.hasLinks = true; + } + this.literal.write(bytes, length, bytes.length - length); + this.literal.write(terminator); + this.link.reset(); + this.state = State.CONTENT; + } + + /** + * Write the current link back as literal content and resume parsing. + */ + private void abortLink() { + this.literal.write(this.link.toByteArray(), 0, this.link.size()); + this.link.reset(); + this.state = State.CONTENT; + } + + private void flushLiteral() { + if (this.literal.size() > 0) { + this.tokens.add(new Token(this.literal.toByteArray(), false)); + this.literal.reset(); + } + } + + private List flushTokens() { + flushLiteral(); + List result = this.tokens; + this.tokens = new ArrayList<>(); + return result; + } + + + /** + * Represents the internal state of the {@link CssLinkParser}, which processes + * the input one byte at a time. The flow is shown below: + *

+	 *                       no match
+	 *               +-----------------------+
+	 *               v                       |
+	 *   +------> CONTENT --"u" or "@"--> KEYWORD
+	 *   ^           ^                       |
+	 *   |           |                       | "url(" or "@import"
+	 *   |           |  other byte after     v
+	 *   |           +----"@import"---- BEFORE_LINK <--+
+	 *   |                               |   |   |     | whitespace
+	 *   |                               |   |   +-----+
+	 *   |              quote            |   |
+	 *   |      +------------------------+   +-----------------+
+	 *   |      |                              other byte      |
+	 *   |      v                              after "url("    v
+	 *   +<-QUOTED_LINK                                  UNQUOTED_LINK
+	 *   ^  closing quote                                      |
+	 *   |  or invalid link                ")" or invalid link |
+	 *   +<----------------------------------------------------+
+	 * 
+ * Links are invalid if they contain an unescaped newline (quoted links), + * a quote, a parenthesis or inner whitespace (unquoted links), or if they + * exceed {@link CssLinkParser#MAX_LINK_LENGTH}. Invalid links are written back + * as literal content, and the byte that invalidated them is re-examined as + * {@link #CONTENT}. + */ + private enum State { + + CONTENT { + @Override + boolean process(byte b, CssLinkParser parser) { + parser.literal.write(b); + // "url(" keyword, unless part of a longer identifier + if (b == URL_KEYWORD[0] && !parser.previousWasIdentifierChar) { + beginKeyword(parser, URL_KEYWORD); + } + // "@import" keyword + else if (b == IMPORT_KEYWORD[0]) { + beginKeyword(parser, IMPORT_KEYWORD); + } + return false; + } + + private void beginKeyword(CssLinkParser parser, byte[] keyword) { + parser.keyword = keyword; + parser.matched = 1; + parser.state = State.KEYWORD; + } + }, + + KEYWORD { + @Override + boolean process(byte b, CssLinkParser parser) { + // current byte matching the keyword + if (parser.matched < parser.keyword.length && b == parser.keyword[parser.matched]) { + parser.literal.write(b); + parser.matched++; + if (parser.keyword == URL_KEYWORD && parser.matched == URL_KEYWORD.length) { + parser.state = State.BEFORE_LINK; + } + return false; + } + // "@import" must not be part of a longer identifier; + // other bytes are re-examined as content. + boolean complete = (parser.matched == parser.keyword.length && !isIdentifierChar(b)); + parser.state = (complete ? State.BEFORE_LINK : State.CONTENT); + return true; + } + }, + + BEFORE_LINK { + @Override + boolean process(byte b, CssLinkParser parser) { + if (isCssWhitespace(b)) { + parser.literal.write(b); + return false; + } + if (b == '\'' || b == '"') { + parser.literal.write(b); + parser.terminator = b; + parser.state = State.QUOTED_LINK; + return false; + } + if (parser.keyword == URL_KEYWORD) { + parser.linkEnd = -1; + parser.state = State.UNQUOTED_LINK; + return true; + } + // @import with url(...) following. + parser.state = State.CONTENT; + return true; + } + }, + + QUOTED_LINK { + @Override + boolean process(byte b, CssLinkParser parser) { + if (b == parser.terminator) { + parser.completeLink(parser.link.size(), b); + return false; + } + // unescaped newlines are not allowed in CSS strings + if (b == '\n' || b == '\r' || b == '\f') { + parser.abortLink(); + return true; + } + parser.appendToLink(b); + return false; + } + }, + + UNQUOTED_LINK { + @Override + boolean process(byte b, CssLinkParser parser) { + if (b == ')') { + parser.completeLink((parser.linkEnd != -1 ? parser.linkEnd : parser.link.size()), b); + return false; + } + if (isCssWhitespace(b)) { + if (parser.linkEnd == -1) { + parser.linkEnd = parser.link.size(); + } + parser.appendToLink(b); + return false; + } + // only whitespace is allowed before the closing parenthesis, + // and quotes and parentheses are not allowed in unquoted urls + if (parser.linkEnd != -1 || b == '\'' || b == '"' || b == '(') { + parser.abortLink(); + return true; + } + parser.appendToLink(b); + return false; + } + }; + + abstract boolean process(byte b, CssLinkParser parser); + + private static boolean isIdentifierChar(byte b) { + return ((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || + b == '-' || b == '_' || b < 0); + } + + private static boolean isCssWhitespace(byte b) { + return (b == ' ' || b == '\t' || b == '\n' || b == '\r' || b == '\f'); + } + } + + + /** + * A span of bytes emitted by the parser: either literal content to write + * through unchanged, or the contents of a link (without its surrounding + * keyword, whitespace, quotes or parenthesis). + * @param bytes the span of bytes + * @param link whether {@code bytes} is a link, as opposed to literal content + */ + record Token(byte[] bytes, boolean link) { + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkResourceTransformer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkResourceTransformer.java index a8bb5872494..22ea2e206fd 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkResourceTransformer.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkResourceTransformer.java @@ -16,19 +16,11 @@ package org.springframework.web.reactive.resource; -import java.io.StringWriter; +import java.io.ByteArrayOutputStream; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; -import java.util.Set; -import java.util.SortedSet; -import java.util.TreeSet; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.jspecify.annotations.Nullable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -52,22 +44,13 @@ import org.springframework.web.server.ServerWebExchange; * the original link is preserved. * * @author Rossen Stoyanchev + * @author Brian Clozel * @since 5.0 */ public class CssLinkResourceTransformer extends ResourceTransformerSupport { private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; - private static final Log logger = LogFactory.getLog(CssLinkResourceTransformer.class); - - private final List linkParsers = new ArrayList<>(2); - - - public CssLinkResourceTransformer() { - this.linkParsers.add(new ImportLinkParser()); - this.linkParsers.add(new UrlFunctionLinkParser()); - } - @Override @SuppressWarnings("deprecation") @@ -83,63 +66,52 @@ public class CssLinkResourceTransformer extends ResourceTransformerSupport { } DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory(); - Flux flux = DataBufferUtils - .read(outputResource, bufferFactory, StreamUtils.BUFFER_SIZE); - return DataBufferUtils.join(flux) - .flatMap(dataBuffer -> { - String cssContent = dataBuffer.toString(DEFAULT_CHARSET); - DataBufferUtils.release(dataBuffer); - return transformContent(cssContent, outputResource, transformerChain, exchange); - }); + return transformContent(outputResource, bufferFactory, transformerChain, exchange); }); } - private Mono transformContent(String cssContent, Resource resource, + private Mono transformContent(Resource resource, DataBufferFactory bufferFactory, ResourceTransformerChain chain, ServerWebExchange exchange) { - List contentChunkInfos = parseContent(cssContent); - if (contentChunkInfos.isEmpty()) { - return Mono.just(resource); - } - - return Flux.fromIterable(contentChunkInfos) - .concatMap(contentChunkInfo -> { - String contentChunk = contentChunkInfo.getContent(cssContent); - if (contentChunkInfo.isLink() && !hasScheme(contentChunk)) { - String link = toAbsolutePath(contentChunk, exchange); - return resolveUrlPath(link, exchange, resource, chain).defaultIfEmpty(contentChunk); - } - else { - return Mono.just(contentChunk); - } - }) - .reduce(new StringWriter(), (writer, chunk) -> { - writer.write(chunk); - return writer; - }) - .map(writer -> { - byte[] newContent = writer.toString().getBytes(DEFAULT_CHARSET); - return new TransformedResource(resource, newContent); - }); + // Parsing state is created per subscription + return Mono.defer(() -> { + CssLinkParser parser = new CssLinkParser(); + Flux flux = DataBufferUtils.read(resource, bufferFactory, StreamUtils.BUFFER_SIZE); + return flux + .concatMap(buffer -> Flux.fromIterable(feedAndRelease(parser, buffer))) + .concatWith(Flux.defer(() -> Flux.fromIterable(parser.end()))) + .concatMap(token -> resolveToken(token, resource, chain, exchange)) + .reduceWith(ByteArrayOutputStream::new, (out, bytes) -> { + out.write(bytes, 0, bytes.length); + return out; + }) + .map(out -> parser.hasLinks() ? new TransformedResource(resource, out.toByteArray()) : resource); + }); } - private List parseContent(String cssContent) { - SortedSet links = new TreeSet<>(); - this.linkParsers.forEach(parser -> parser.parse(cssContent, links)); - if (links.isEmpty()) { - return Collections.emptyList(); + private List feedAndRelease(CssLinkParser parser, DataBuffer buffer) { + try { + return parser.feed(buffer); } - int index = 0; - List result = new ArrayList<>(); - for (ContentChunkInfo link : links) { - result.add(new ContentChunkInfo(index, link.getStart(), false)); - result.add(link); - index = link.getEnd(); + finally { + DataBufferUtils.release(buffer); } - if (index < cssContent.length()) { - result.add(new ContentChunkInfo(index, cssContent.length(), false)); + } + + private Mono resolveToken(CssLinkParser.Token token, Resource resource, + ResourceTransformerChain chain, ServerWebExchange exchange) { + + if (!token.link()) { + return Mono.just(token.bytes()); } - return result; + String link = new String(token.bytes(), DEFAULT_CHARSET); + if (hasScheme(link)) { + return Mono.just(token.bytes()); + } + String absolutePath = toAbsolutePath(link, exchange); + return resolveUrlPath(absolutePath, exchange, resource, chain) + .defaultIfEmpty(link) + .map(resolved -> resolved.getBytes(DEFAULT_CHARSET)); } private boolean hasScheme(String link) { @@ -147,158 +119,4 @@ public class CssLinkResourceTransformer extends ResourceTransformerSupport { return (schemeIndex > 0 && !link.substring(0, schemeIndex).contains("/")) || link.indexOf("//") == 0; } - - /** - * Extract content chunks that represent links. - */ - @FunctionalInterface - protected interface LinkParser { - - void parse(String cssContent, SortedSet result); - - } - - - /** - * Abstract base class for {@link LinkParser} implementations. - */ - protected abstract static class AbstractLinkParser implements LinkParser { - - /** Return the keyword to use to search for links, for example, "@import", "url(". */ - protected abstract String getKeyword(); - - @Override - public void parse(String content, SortedSet result) { - int position = 0; - while (true) { - position = content.indexOf(getKeyword(), position); - if (position == -1) { - return; - } - position += getKeyword().length(); - while (position < content.length() && Character.isWhitespace(content.charAt(position))) { - position++; - } - if (position == content.length()) { - return; - } - if (content.charAt(position) == '\'') { - position = extractLink(position, '\'', content, result); - } - else if (content.charAt(position) == '"') { - position = extractLink(position, '"', content, result); - } - else { - position = extractUnquotedLink(position, content, result); - } - } - } - - protected int extractLink(int index, char endChar, String content, Set result) { - int start = index + 1; - int end = content.indexOf(endChar, start); - if (end == -1) { - if (logger.isTraceEnabled()) { - logger.trace("Unterminated link at index " + start + ", no closing '" + endChar + "'"); - } - return content.length(); - } - result.add(new ContentChunkInfo(start, end, true)); - return end + 1; - } - - /** - * Invoked after a keyword match, after whitespace has been removed, and when - * the next char is neither a single nor double quote. - */ - protected abstract int extractUnquotedLink(int position, String content, - Set linksToAdd); - - } - - - private static class ImportLinkParser extends AbstractLinkParser { - - @Override - protected String getKeyword() { - return "@import"; - } - - @Override - protected int extractUnquotedLink(int position, String content, Set result) { - if (content.startsWith("url(", position)) { - // Ignore: UrlFunctionLinkParser will handle it. - } - else if (logger.isTraceEnabled()) { - logger.trace("Unexpected syntax for @import link at index " + position); - } - return position; - } - } - - - private static class UrlFunctionLinkParser extends AbstractLinkParser { - - @Override - protected String getKeyword() { - return "url("; - } - - @Override - protected int extractUnquotedLink(int position, String content, Set result) { - // A url() function without unquoted - return extractLink(position - 1, ')', content, result); - } - } - - - private static class ContentChunkInfo implements Comparable { - - private final int start; - - private final int end; - - private final boolean isLink; - - - ContentChunkInfo(int start, int end, boolean isLink) { - this.start = start; - this.end = end; - this.isLink = isLink; - } - - - public int getStart() { - return this.start; - } - - public int getEnd() { - return this.end; - } - - public boolean isLink() { - return this.isLink; - } - - public String getContent(String fullContent) { - return fullContent.substring(this.start, this.end); - } - - @Override - public int compareTo(ContentChunkInfo other) { - return Integer.compare(this.start, other.start); - } - - @Override - public boolean equals(@Nullable Object other) { - return (this == other || (other instanceof ContentChunkInfo that && - this.start == that.start && this.end == that.end)); - } - - @Override - public int hashCode() { - return this.start * 31 + this.end; - } - } - } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/CssLinkParserTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/CssLinkParserTests.java new file mode 100644 index 00000000000..f512b75d4ad --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/CssLinkParserTests.java @@ -0,0 +1,242 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.web.reactive.resource; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link CssLinkParser}. + * @author Brian Clozel + */ +class CssLinkParserTests { + + private static final int[] CHUNK_SIZES = {1, 2, 3, 7, 13, 4096}; + + private static final DataBufferFactory bufferFactory = new DefaultDataBufferFactory(); + + + @Test + void quotedUrlFunctionWithDoubleQuotes() { + assertLinks("body { background: url(\"foo.png\") }", "foo.png"); + } + + @Test + void quotedUrlFunctionWithSingleQuotes() { + assertLinks("body { background: url('foo.png') }", "foo.png"); + } + + @Test + void unquotedUrlFunction() { + assertLinks("body { background: url(foo.png) }", "foo.png"); + } + + @Test + void importWithDoubleQuotedString() { + assertLinks("@import \"foo.css\";", "foo.css"); + } + + @Test + void importWithSingleQuotedString() { + assertLinks("@import 'foo.css';", "foo.css"); + } + + @Test + void importWithQuotedUrlFunction() { + assertLinks("@import url(\"foo.css\");", "foo.css"); + } + + @Test + void importWithUnquotedUrlFunction() { + assertLinks("@import url(foo.css);", "foo.css"); + } + + @Test + void whitespaceInsideParenthesesAroundQuotedLink() { + assertLinks("body { background: url( \"foo.png\" ) }", "foo.png"); + } + + @Test + void whitespaceIncludingNewlinesAndTabsBeforeQuotedLink() { + assertLinks("body { background: url(\n\t'foo.png'\n) }", "foo.png"); + } + + @Test + void tabAfterImportKeyword() { + assertLinks("@import\t\"foo.css\";", "foo.css"); + } + + @Test + void invalidKeywordIsSkipped() { + assertLinks("body { background: uurl(foo.png) }"); + } + + @Test + void urlFunctionAfterNonIdentifierCharacterIsStillAKeyword() { + assertLinks("body { background: (url(foo.png)) }", "foo.png"); + } + + @Test + void importWithoutQuoteOrUrlFunctionProducesNoLink() { + assertLinks("@import foo.css;"); + } + + @Test // https://github.com/spring-projects/spring-framework/issues/22602 + void emptyUrlFunctionProducesNoLink() { + assertLinks(".fooStyle { background: transparent url() no-repeat left top; }"); + } + + @Test + void emptyUrlFunctionWithWhitespaceProducesNoLink() { + assertLinks("body { background: url( ) }"); + } + + @Test + void nonAsciiContentAroundLinkPassesThroughUntouched() { + assertLinks("café { background: url(\"héllo.png\") } 世界", "héllo.png"); + } + + @Test + void nonAsciiContentWithoutAnyLink() { + assertLinks("café { color: red; } 世界"); + } + + @Test + void unterminatedUnquotedLinkIsSkipped() { + assertLinks("body { background: url(images/missing.png"); + } + + @Test + void unterminatedDoubleQuotedLinkIsSkipped() { + assertLinks("body { background: url(\"images/missing.png"); + } + + @Test + void unterminatedSingleQuotedLinkSpanningIntoNextIsSkipped() { + assertLinks("a{background:url('x.png}\nb{color:red}"); + } + + @Test + void endOfFileImmediatelyAfterUrlFunctionKeywordDoesNotThrow() { + assertLinks("body { background: url("); + } + + @Test + void trailingWhitespaceAfterImportAtEndOfFileDoesNotThrow() { + assertLinks("@import "); + } + + @Test + void runawayUnterminatedLinkIsBoundedAndParsingRecovers() { + String longUnterminated = "a".repeat(4096); + assertLinks("body { background: url('" + longUnterminated + " div { background: url('second.png') }", + "second.png"); + } + + @Test + void unterminatedQuotedLinkStopsAtNewlineAndNextLinkIsFound() { + assertLinks("a{background:url('x.png}\nb{background:url('b.png')}", "b.png"); + } + + @Test + void unterminatedUnquotedLinkStopsAtNewlineAndNextLinkIsFound() { + assertLinks("a{background:url(x.png\nb{background:url(b.png)}", "b.png"); + } + + @Test + void unquotedLinkWithTrailingWhitespaceExcludesWhitespace() { + assertLinks("body { background: url( foo.png \t) }", "foo.png"); + } + + @Test + void unquotedLinkWithInnerWhitespaceIsInvalid() { + assertLinks("a{background:url(foo bar.png)} b{background:url(b.png)}", "b.png"); + } + + @Test + void unquotedLinkWithQuoteIsInvalid() { + assertLinks("a{background:url(foo'bar.png)} b{background:url(b.png)}", "b.png"); + } + + @Test + void unquotedLinkWithParenthesisIsInvalid() { + assertLinks("a{background:url(foo(bar.png)} b{background:url(b.png)}", "b.png"); + } + + @Test + void unterminatedUnquotedLinkWithTrailingWhitespaceAtEndOfFile() { + assertLinks("body { background: url(foo.png "); + } + + @Test + void unterminatedLinksIsSkipped() { + String pathological = "url(x".repeat(200_000); + assertLinks(pathological); + } + + + private static void assertLinks(String cssContent, String... expectedLinks) { + byte[] input = cssContent.getBytes(UTF_8); + for (int chunkSize : CHUNK_SIZES) { + List links = new ArrayList<>(); + byte[] output = parse(input, chunkSize, links); + assertThat(output) + .describedAs("reconstructed output at chunk size " + chunkSize) + .isEqualTo(input); + assertThat(links) + .describedAs("extracted links at chunk size " + chunkSize) + .containsExactly(expectedLinks); + } + } + + private static byte[] parse(byte[] input, int chunkSize, List links) { + CssLinkParser parser = new CssLinkParser(); + List tokens = new ArrayList<>(); + for (int i = 0; i < input.length; i += chunkSize) { + int len = Math.min(chunkSize, input.length - i); + DataBuffer buffer = bufferFactory.wrap(Arrays.copyOfRange(input, i, i + len)); + try { + tokens.addAll(parser.feed(buffer)); + } + finally { + DataBufferUtils.release(buffer); + } + } + tokens.addAll(parser.end()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + for (CssLinkParser.Token token : tokens) { + output.write(token.bytes(), 0, token.bytes().length); + if (token.link()) { + links.add(new String(token.bytes(), UTF_8)); + } + } + return output.toByteArray(); + } + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/CssLinkResourceTransformerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/CssLinkResourceTransformerTests.java index 7fca7166b97..a1ae8f6136c 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/CssLinkResourceTransformerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/CssLinkResourceTransformerTests.java @@ -167,18 +167,12 @@ class CssLinkResourceTransformerTests { @Test // https://github.com/spring-projects/spring-framework/issues/22602 void transformEmptyUrlFunction() { MockServerWebExchange exchange = MockServerWebExchange.from(get("/static/empty_url_function.css")); - Resource css = getResource("empty_url_function.css"); - String expected = """ - .fooStyle { - background: transparent url() no-repeat left top; - }"""; + Resource expected = getResource("empty_url_function.css"); - StepVerifier.create(this.transformerChain.transform(exchange, css) - .cast(TransformedResource.class)) - .consumeNextWith(transformedResource -> { - String result = new String(transformedResource.getByteArray(), UTF_8); - assertThat(result).isEqualToNormalizingNewlines(expected); - }) + // An empty url() is not a link at all, so the resource is left untouched, + // exactly like a CSS file that has no link in it at all. + StepVerifier.create(this.transformerChain.transform(exchange, expected)) + .consumeNextWith(resource -> assertThat(resource).isSameAs(expected)) .expectComplete() .verify(); } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/CssLinkParser.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/CssLinkParser.java new file mode 100644 index 00000000000..6103b842968 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/CssLinkParser.java @@ -0,0 +1,316 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.web.servlet.resource; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * A lightweight, forward-only parser that recognizes CSS {@code url()} + * and {@code @import} link tokens. + * + *

This parser is used by {@link CssLinkResourceTransformer} to rewrite + * CSS links on-the-fly, if needed. Other tokens are handed back as is + * to be written to the output stream. Invalid links (unterminated, containing + * characters not allowed by the CSS syntax, or exceeding {@link #MAX_LINK_LENGTH}) + * are skipped and written as-is, and parsing resumes right after them. + * + * @author Brian Clozel + * @since 7.1 + */ +final class CssLinkParser { + + private static final byte[] URL_KEYWORD = {'u', 'r', 'l', '('}; + + private static final byte[] IMPORT_KEYWORD = {'@', 'i', 'm', 'p', 'o', 'r', 't'}; + + private static final int MAX_LINK_LENGTH = 2048; + + + private State state = State.CONTENT; + + private byte[] keyword = URL_KEYWORD; + + private int matched; + + private byte terminator; + + /** Length of an unquoted link, excluding trailing whitespace, or -1 if none seen yet. */ + private int linkEnd = -1; + + /** Whether the previous byte was an identifier character. */ + private boolean previousWasIdentifierChar; + + private boolean hasLinks; + + private final ByteArrayOutputStream literal = new ByteArrayOutputStream(); + + private final ByteArrayOutputStream link = new ByteArrayOutputStream(); + + private List tokens = new ArrayList<>(); + + + /** + * Feed the next chunk of input to the parser. + * @return the tokens produced by this chunk + */ + List feed(byte[] bytes, int offset, int length) { + for (int i = offset; i < offset + length; i++) { + processByte(bytes[i]); + } + return flushTokens(); + } + + /** + * Signal the end of the input. + * @return the remaining tokens + */ + List end() { + abortLink(); + return flushTokens(); + } + + /** + * Whether at least one link token was produced so far. + */ + boolean hasLinks() { + return this.hasLinks; + } + + private void processByte(byte b) { + boolean reexamine; + do { + reexamine = this.state.process(b, this); + } + while (reexamine); + this.previousWasIdentifierChar = State.isIdentifierChar(b); + } + + private void appendToLink(byte b) { + this.link.write(b); + // ignore links larger than the maximum length + if (this.link.size() > MAX_LINK_LENGTH) { + abortLink(); + } + } + + /** + * Emit the first {@code length} bytes of the current link as a link token, + * followed by any remaining bytes and the terminator as literal content. + */ + private void completeLink(int length, byte terminator) { + flushLiteral(); + byte[] bytes = this.link.toByteArray(); + if (length > 0) { + this.tokens.add(new Token((length < bytes.length ? Arrays.copyOf(bytes, length) : bytes), true)); + this.hasLinks = true; + } + this.literal.write(bytes, length, bytes.length - length); + this.literal.write(terminator); + this.link.reset(); + this.state = State.CONTENT; + } + + /** + * Write the current link back as literal content and resume parsing. + */ + private void abortLink() { + this.literal.write(this.link.toByteArray(), 0, this.link.size()); + this.link.reset(); + this.state = State.CONTENT; + } + + private void flushLiteral() { + if (this.literal.size() > 0) { + this.tokens.add(new Token(this.literal.toByteArray(), false)); + this.literal.reset(); + } + } + + private List flushTokens() { + flushLiteral(); + List result = this.tokens; + this.tokens = new ArrayList<>(); + return result; + } + + + /** + * Represents the internal state of the {@link CssLinkParser}, which processes + * the input one byte at a time. The flow is shown below: + *

+	 *                       no match
+	 *               +-----------------------+
+	 *               v                       |
+	 *   +------> CONTENT --"u" or "@"--> KEYWORD
+	 *   ^           ^                       |
+	 *   |           |                       | "url(" or "@import"
+	 *   |           |  other byte after     v
+	 *   |           +----"@import"---- BEFORE_LINK <--+
+	 *   |                               |   |   |     | whitespace
+	 *   |                               |   |   +-----+
+	 *   |              quote            |   |
+	 *   |      +------------------------+   +-----------------+
+	 *   |      |                              other byte      |
+	 *   |      v                              after "url("    v
+	 *   +<-QUOTED_LINK                                  UNQUOTED_LINK
+	 *   ^  closing quote                                      |
+	 *   |  or invalid link                ")" or invalid link |
+	 *   +<----------------------------------------------------+
+	 * 
+ * Links are invalid if they contain an unescaped newline (quoted links), + * a quote, a parenthesis or inner whitespace (unquoted links), or if they + * exceed {@link CssLinkParser#MAX_LINK_LENGTH}. Invalid links are written back + * as literal content, and the byte that invalidated them is re-examined as + * {@link #CONTENT}. + */ + private enum State { + + CONTENT { + @Override + boolean process(byte b, CssLinkParser parser) { + parser.literal.write(b); + // "url(" keyword, unless part of a longer identifier + if (b == URL_KEYWORD[0] && !parser.previousWasIdentifierChar) { + beginKeyword(parser, URL_KEYWORD); + } + // "@import" keyword + else if (b == IMPORT_KEYWORD[0]) { + beginKeyword(parser, IMPORT_KEYWORD); + } + return false; + } + + private void beginKeyword(CssLinkParser parser, byte[] keyword) { + parser.keyword = keyword; + parser.matched = 1; + parser.state = State.KEYWORD; + } + }, + + KEYWORD { + @Override + boolean process(byte b, CssLinkParser parser) { + // current byte matching the keyword + if (parser.matched < parser.keyword.length && b == parser.keyword[parser.matched]) { + parser.literal.write(b); + parser.matched++; + if (parser.keyword == URL_KEYWORD && parser.matched == URL_KEYWORD.length) { + parser.state = State.BEFORE_LINK; + } + return false; + } + // "@import" must not be part of a longer identifier; + // other bytes are re-examined as content. + boolean complete = (parser.matched == parser.keyword.length && !isIdentifierChar(b)); + parser.state = (complete ? State.BEFORE_LINK : State.CONTENT); + return true; + } + }, + + BEFORE_LINK { + @Override + boolean process(byte b, CssLinkParser parser) { + if (isCssWhitespace(b)) { + parser.literal.write(b); + return false; + } + if (b == '\'' || b == '"') { + parser.literal.write(b); + parser.terminator = b; + parser.state = State.QUOTED_LINK; + return false; + } + if (parser.keyword == URL_KEYWORD) { + parser.linkEnd = -1; + parser.state = State.UNQUOTED_LINK; + return true; + } + // @import with url(...) following. + parser.state = State.CONTENT; + return true; + } + }, + + QUOTED_LINK { + @Override + boolean process(byte b, CssLinkParser parser) { + if (b == parser.terminator) { + parser.completeLink(parser.link.size(), b); + return false; + } + // unescaped newlines are not allowed in CSS strings + if (b == '\n' || b == '\r' || b == '\f') { + parser.abortLink(); + return true; + } + parser.appendToLink(b); + return false; + } + }, + + UNQUOTED_LINK { + @Override + boolean process(byte b, CssLinkParser parser) { + if (b == ')') { + parser.completeLink((parser.linkEnd != -1 ? parser.linkEnd : parser.link.size()), b); + return false; + } + if (isCssWhitespace(b)) { + if (parser.linkEnd == -1) { + parser.linkEnd = parser.link.size(); + } + parser.appendToLink(b); + return false; + } + // only whitespace is allowed before the closing parenthesis, + // and quotes and parentheses are not allowed in unquoted urls + if (parser.linkEnd != -1 || b == '\'' || b == '"' || b == '(') { + parser.abortLink(); + return true; + } + parser.appendToLink(b); + return false; + } + }; + + abstract boolean process(byte b, CssLinkParser parser); + + private static boolean isIdentifierChar(byte b) { + return ((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || + b == '-' || b == '_' || b < 0); + } + + private static boolean isCssWhitespace(byte b) { + return (b == ' ' || b == '\t' || b == '\n' || b == '\r' || b == '\f'); + } + } + + + /** + * A span of bytes emitted by the parser: either literal content to write + * through unchanged, or the contents of a link (without its surrounding + * keyword, whitespace, quotes or parenthesis). + * @param bytes the span of bytes + * @param link whether {@code bytes} is a link, as opposed to literal content + */ + record Token(byte[] bytes, boolean link) { + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/CssLinkResourceTransformer.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/CssLinkResourceTransformer.java index 136d8d2c619..e0835947b99 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/CssLinkResourceTransformer.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/CssLinkResourceTransformer.java @@ -16,22 +16,17 @@ package org.springframework.web.servlet.resource; +import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.StringWriter; +import java.io.InputStream; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.List; -import java.util.SortedSet; -import java.util.TreeSet; import jakarta.servlet.http.HttpServletRequest; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.jspecify.annotations.Nullable; import org.springframework.core.io.Resource; -import org.springframework.util.FileCopyUtils; +import org.springframework.util.StreamUtils; import org.springframework.util.StringUtils; /** @@ -46,22 +41,13 @@ import org.springframework.util.StringUtils; * the original link is preserved. * * @author Rossen Stoyanchev + * @author Brian Clozel * @since 4.1 */ public class CssLinkResourceTransformer extends ResourceTransformerSupport { private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; - private static final Log logger = LogFactory.getLog(CssLinkResourceTransformer.class); - - private final List linkParsers = new ArrayList<>(2); - - - public CssLinkResourceTransformer() { - this.linkParsers.add(new ImportStatementLinkParser()); - this.linkParsers.add(new UrlFunctionLinkParser()); - } - @SuppressWarnings("deprecation") @Override @@ -76,34 +62,42 @@ public class CssLinkResourceTransformer extends ResourceTransformerSupport { return resource; } - byte[] bytes = FileCopyUtils.copyToByteArray(resource.getInputStream()); - String content = new String(bytes, DEFAULT_CHARSET); - - SortedSet links = new TreeSet<>(); - for (LinkParser parser : this.linkParsers) { - parser.parse(content, links); + CssLinkParser parser = new CssLinkParser(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[StreamUtils.BUFFER_SIZE]; + try (InputStream in = resource.getInputStream()) { + int read; + while ((read = in.read(buffer)) != -1) { + writeTokens(parser.feed(buffer, 0, read), request, resource, transformerChain, output); + } } + writeTokens(parser.end(), request, resource, transformerChain, output); - if (links.isEmpty()) { + if (!parser.hasLinks()) { return resource; } + return new TransformedResource(resource, output.toByteArray()); + } - int index = 0; - StringWriter writer = new StringWriter(); - for (ContentChunkInfo linkContentChunkInfo : links) { - writer.write(content.substring(index, linkContentChunkInfo.getStart())); - String link = content.substring(linkContentChunkInfo.getStart(), linkContentChunkInfo.getEnd()); - String newLink = null; - if (!hasScheme(link)) { - String absolutePath = toAbsolutePath(link, request); - newLink = resolveUrlPath(absolutePath, request, resource, transformerChain); - } - writer.write(newLink != null ? newLink : link); - index = linkContentChunkInfo.getEnd(); + private void writeTokens(List tokens, HttpServletRequest request, Resource resource, + ResourceTransformerChain transformerChain, ByteArrayOutputStream output) { + + for (CssLinkParser.Token token : tokens) { + byte[] bytes = (token.link() ? resolveLink(token.bytes(), request, resource, transformerChain) : token.bytes()); + output.write(bytes, 0, bytes.length); } - writer.write(content.substring(index)); + } - return new TransformedResource(resource, writer.toString().getBytes(DEFAULT_CHARSET)); + private byte[] resolveLink(byte[] linkBytes, HttpServletRequest request, Resource resource, + ResourceTransformerChain transformerChain) { + + String link = new String(linkBytes, DEFAULT_CHARSET); + String newLink = null; + if (!hasScheme(link)) { + String absolutePath = toAbsolutePath(link, request); + newLink = resolveUrlPath(absolutePath, request, resource, transformerChain); + } + return (newLink != null ? newLink.getBytes(DEFAULT_CHARSET) : linkBytes); } private boolean hasScheme(String link) { @@ -111,143 +105,4 @@ public class CssLinkResourceTransformer extends ResourceTransformerSupport { return ((schemeIndex > 0 && !link.substring(0, schemeIndex).contains("/")) || link.indexOf("//") == 0); } - - /** - * Extract content chunks that represent links. - */ - @FunctionalInterface - protected interface LinkParser { - - void parse(String content, SortedSet result); - - } - - - /** - * Abstract base class for {@link LinkParser} implementations. - */ - protected abstract static class AbstractLinkParser implements LinkParser { - - /** Return the keyword to use to search for links, for example, "@import", "url(". */ - protected abstract String getKeyword(); - - @Override - public void parse(String content, SortedSet result) { - int position = 0; - while (true) { - position = content.indexOf(getKeyword(), position); - if (position == -1) { - return; - } - position += getKeyword().length(); - while (position < content.length() && Character.isWhitespace(content.charAt(position))) { - position++; - } - if (position == content.length()) { - return; - } - if (content.charAt(position) == '\'') { - position = extractLink(position, "'", content, result); - } - else if (content.charAt(position) == '"') { - position = extractLink(position, "\"", content, result); - } - else { - position = extractLink(position, content, result); - } - } - } - - protected int extractLink(int index, String endKey, String content, SortedSet linksToAdd) { - int start = index + 1; - int end = content.indexOf(endKey, start); - if (end == -1) { - if (logger.isTraceEnabled()) { - logger.trace("Unterminated link at index " + start + ", no closing \"" + endKey + "\""); - } - return content.length(); - } - linksToAdd.add(new ContentChunkInfo(start, end)); - return end + endKey.length(); - } - - /** - * Invoked after a keyword match, after whitespace has been removed, and when - * the next char is neither a single nor double quote. - */ - protected abstract int extractLink(int index, String content, SortedSet linksToAdd); - } - - - private static class ImportStatementLinkParser extends AbstractLinkParser { - - @Override - protected String getKeyword() { - return "@import"; - } - - @Override - protected int extractLink(int index, String content, SortedSet linksToAdd) { - if (content.startsWith("url(", index)) { - // Ignore: UrlFunctionLinkParser will handle it. - } - else if (logger.isTraceEnabled()) { - logger.trace("Unexpected syntax for @import link at index " + index); - } - return index; - } - } - - - private static class UrlFunctionLinkParser extends AbstractLinkParser { - - @Override - protected String getKeyword() { - return "url("; - } - - @Override - protected int extractLink(int index, String content, SortedSet linksToAdd) { - // A url() function without unquoted - return extractLink(index - 1, ")", content, linksToAdd); - } - } - - - private static class ContentChunkInfo implements Comparable { - - private final int start; - - private final int end; - - ContentChunkInfo(int start, int end) { - this.start = start; - this.end = end; - } - - public int getStart() { - return this.start; - } - - public int getEnd() { - return this.end; - } - - @Override - public int compareTo(ContentChunkInfo other) { - return Integer.compare(this.start, other.start); - } - - @Override - public boolean equals(@Nullable Object other) { - return (this == other || (other instanceof ContentChunkInfo that && - this.start == that.start && this.end == that.end)); - } - - @Override - public int hashCode() { - return this.start * 31 + this.end; - } - } - } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/CssLinkParserTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/CssLinkParserTests.java new file mode 100644 index 00000000000..68da619b0ae --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/CssLinkParserTests.java @@ -0,0 +1,234 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.web.servlet.resource; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link CssLinkParser}. + * + *

Every case is run at chunk sizes 1, 2, 3, 7, 13 and 4096 bytes, asserting + * identical output at every chunk size: that is what proves the parser is safe + * across arbitrary buffer boundaries (relevant to the webflux {@code DataBuffer} + * based caller in particular). + * + * @author Brian Clozel + */ +class CssLinkParserTests { + + private static final int[] CHUNK_SIZES = {1, 2, 3, 7, 13, 4096}; + + + @Test + void quotedUrlFunctionWithDoubleQuotes() { + assertLinks("body { background: url(\"foo.png\") }", "foo.png"); + } + + @Test + void quotedUrlFunctionWithSingleQuotes() { + assertLinks("body { background: url('foo.png') }", "foo.png"); + } + + @Test + void unquotedUrlFunction() { + assertLinks("body { background: url(foo.png) }", "foo.png"); + } + + @Test + void importWithDoubleQuotedString() { + assertLinks("@import \"foo.css\";", "foo.css"); + } + + @Test + void importWithSingleQuotedString() { + assertLinks("@import 'foo.css';", "foo.css"); + } + + @Test + void importWithQuotedUrlFunction() { + assertLinks("@import url(\"foo.css\");", "foo.css"); + } + + @Test + void importWithUnquotedUrlFunction() { + assertLinks("@import url(foo.css);", "foo.css"); + } + + @Test + void whitespaceInsideParenthesesAroundQuotedLink() { + assertLinks("body { background: url( \"foo.png\" ) }", "foo.png"); + } + + @Test + void whitespaceIncludingNewlinesAndTabsBeforeQuotedLink() { + assertLinks("body { background: url(\n\t'foo.png'\n) }", "foo.png"); + } + + @Test + void tabAfterImportKeyword() { + assertLinks("@import\t\"foo.css\";", "foo.css"); + } + + @Test + void invalidKeywordIsSkipped() { + assertLinks("body { background: uurl(foo.png) }"); + } + + @Test + void urlFunctionAfterNonIdentifierCharacterIsStillAKeyword() { + assertLinks("body { background: (url(foo.png)) }", "foo.png"); + } + + @Test + void importWithoutQuoteOrUrlFunctionProducesNoLink() { + assertLinks("@import foo.css;"); + } + + @Test // https://github.com/spring-projects/spring-framework/issues/22602 + void emptyUrlFunctionProducesNoLink() { + assertLinks(".fooStyle { background: transparent url() no-repeat left top; }"); + } + + @Test + void emptyUrlFunctionWithWhitespaceProducesNoLink() { + assertLinks("body { background: url( ) }"); + } + + @Test + void nonAsciiContentAroundLinkPassesThroughUntouched() { + assertLinks("café { background: url(\"héllo.png\") } 世界", "héllo.png"); + } + + @Test + void nonAsciiContentWithoutAnyLink() { + assertLinks("café { color: red; } 世界"); + } + + @Test + void unterminatedUnquotedLinkIsSkipped() { + assertLinks("body { background: url(images/missing.png"); + } + + @Test + void unterminatedDoubleQuotedLinkIsSkipped() { + assertLinks("body { background: url(\"images/missing.png"); + } + + @Test + void unterminatedSingleQuotedLinkSpanningIntoNextIsSkipped() { + assertLinks("a{background:url('x.png}\nb{color:red}"); + } + + @Test + void endOfFileImmediatelyAfterUrlFunctionKeywordDoesNotThrow() { + assertLinks("body { background: url("); + } + + @Test + void trailingWhitespaceAfterImportAtEndOfFileDoesNotThrow() { + assertLinks("@import "); + } + + @Test + void runawayUnterminatedLinkIsBoundedAndParsingRecovers() { + String longUnterminated = "a".repeat(4096); + assertLinks("body { background: url('" + longUnterminated + " div { background: url('second.png') }", + "second.png"); + } + + @Test + void unterminatedQuotedLinkStopsAtNewlineAndNextLinkIsFound() { + assertLinks("a{background:url('x.png}\nb{background:url('b.png')}", "b.png"); + } + + @Test + void unterminatedUnquotedLinkStopsAtNewlineAndNextLinkIsFound() { + assertLinks("a{background:url(x.png\nb{background:url(b.png)}", "b.png"); + } + + @Test + void unquotedLinkWithTrailingWhitespaceExcludesWhitespace() { + assertLinks("body { background: url( foo.png \t) }", "foo.png"); + } + + @Test + void unquotedLinkWithInnerWhitespaceIsInvalid() { + assertLinks("a{background:url(foo bar.png)} b{background:url(b.png)}", "b.png"); + } + + @Test + void unquotedLinkWithQuoteIsInvalid() { + assertLinks("a{background:url(foo'bar.png)} b{background:url(b.png)}", "b.png"); + } + + @Test + void unquotedLinkWithParenthesisIsInvalid() { + assertLinks("a{background:url(foo(bar.png)} b{background:url(b.png)}", "b.png"); + } + + @Test + void unterminatedUnquotedLinkWithTrailingWhitespaceAtEndOfFile() { + assertLinks("body { background: url(foo.png "); + } + + @Test + void unterminatedLinksIsSkipped() { + String pathological = "url(x".repeat(200_000); + assertLinks(pathological); + } + + + private static void assertLinks(String cssContent, String... expectedLinks) { + byte[] input = cssContent.getBytes(UTF_8); + for (int chunkSize : CHUNK_SIZES) { + List links = new ArrayList<>(); + byte[] output = parse(input, chunkSize, links); + assertThat(output) + .describedAs("reconstructed output at chunk size " + chunkSize) + .isEqualTo(input); + assertThat(links) + .describedAs("extracted links at chunk size " + chunkSize) + .containsExactly(expectedLinks); + } + } + + private static byte[] parse(byte[] input, int chunkSize, List links) { + CssLinkParser parser = new CssLinkParser(); + List tokens = new ArrayList<>(); + for (int i = 0; i < input.length; i += chunkSize) { + int len = Math.min(chunkSize, input.length - i); + tokens.addAll(parser.feed(input, i, len)); + } + tokens.addAll(parser.end()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + for (CssLinkParser.Token token : tokens) { + output.write(token.bytes(), 0, token.bytes().length); + if (token.link()) { + links.add(new String(token.bytes(), UTF_8)); + } + } + return output.toByteArray(); + } + +} diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/CssLinkResourceTransformerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/CssLinkResourceTransformerTests.java index f5525a9a298..58e7c9891d3 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/CssLinkResourceTransformerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/CssLinkResourceTransformerTests.java @@ -156,15 +156,12 @@ class CssLinkResourceTransformerTests { @Test // https://github.com/spring-projects/spring-framework/issues/22602 void transformEmptyUrlFunction() throws Exception { this.request = new MockHttpServletRequest("GET", "/static/empty_url_function.css"); - Resource css = getResource("empty_url_function.css"); - String expected = """ - .fooStyle { - background: transparent url() no-repeat left top; - }"""; + Resource expected = getResource("empty_url_function.css"); - TransformedResource actual = (TransformedResource) this.transformerChain.transform(this.request, css); - String result = new String(actual.getByteArray(), UTF_8); - assertThat(result).isEqualToNormalizingNewlines(expected); + // An empty url() is not a link at all, so the resource is left untouched, + // exactly like a CSS file that has no link in it at all. + Resource actual = this.transformerChain.transform(this.request, expected); + assertThat(actual).isSameAs(expected); } @Test