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 extends Resource> transformContent(String cssContent, Resource resource,
+ private Mono extends Resource> 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