mirror of
https://github.com/spring-projects/spring-framework.git
synced 2026-10-07 23:49:08 +00:00
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
This commit is contained in:
+322
@@ -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.
|
||||
*
|
||||
* <p>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<Token> 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<Token> 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<Token> 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<Token> flushTokens() {
|
||||
flushLiteral();
|
||||
List<Token> 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:
|
||||
* <p><pre>
|
||||
* 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 |
|
||||
* +<----------------------------------------------------+
|
||||
* </pre>
|
||||
* 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) {
|
||||
}
|
||||
|
||||
}
|
||||
+38
-220
@@ -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<LinkParser> 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<DataBuffer> 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<ContentChunkInfo> 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<DataBuffer> 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<ContentChunkInfo> parseContent(String cssContent) {
|
||||
SortedSet<ContentChunkInfo> links = new TreeSet<>();
|
||||
this.linkParsers.forEach(parser -> parser.parse(cssContent, links));
|
||||
if (links.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
private List<CssLinkParser.Token> feedAndRelease(CssLinkParser parser, DataBuffer buffer) {
|
||||
try {
|
||||
return parser.feed(buffer);
|
||||
}
|
||||
int index = 0;
|
||||
List<ContentChunkInfo> 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<byte[]> 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<ContentChunkInfo> 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<ContentChunkInfo> 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<ContentChunkInfo> 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<ContentChunkInfo> linksToAdd);
|
||||
|
||||
}
|
||||
|
||||
|
||||
private static class ImportLinkParser extends AbstractLinkParser {
|
||||
|
||||
@Override
|
||||
protected String getKeyword() {
|
||||
return "@import";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int extractUnquotedLink(int position, String content, Set<ContentChunkInfo> 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<ContentChunkInfo> result) {
|
||||
// A url() function without unquoted
|
||||
return extractLink(position - 1, ')', content, result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static class ContentChunkInfo implements Comparable<ContentChunkInfo> {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+242
@@ -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<String> 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<String> links) {
|
||||
CssLinkParser parser = new CssLinkParser();
|
||||
List<CssLinkParser.Token> 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();
|
||||
}
|
||||
|
||||
}
|
||||
+5
-11
@@ -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();
|
||||
}
|
||||
|
||||
+316
@@ -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.
|
||||
*
|
||||
* <p>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<Token> tokens = new ArrayList<>();
|
||||
|
||||
|
||||
/**
|
||||
* Feed the next chunk of input to the parser.
|
||||
* @return the tokens produced by this chunk
|
||||
*/
|
||||
List<Token> 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<Token> 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<Token> flushTokens() {
|
||||
flushLiteral();
|
||||
List<Token> 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:
|
||||
* <p><pre>
|
||||
* 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 |
|
||||
* +<----------------------------------------------------+
|
||||
* </pre>
|
||||
* 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) {
|
||||
}
|
||||
|
||||
}
|
||||
+33
-178
@@ -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<LinkParser> 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<ContentChunkInfo> 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<CssLinkParser.Token> 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<ContentChunkInfo> 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<ContentChunkInfo> 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<ContentChunkInfo> 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<ContentChunkInfo> linksToAdd);
|
||||
}
|
||||
|
||||
|
||||
private static class ImportStatementLinkParser extends AbstractLinkParser {
|
||||
|
||||
@Override
|
||||
protected String getKeyword() {
|
||||
return "@import";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int extractLink(int index, String content, SortedSet<ContentChunkInfo> 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<ContentChunkInfo> linksToAdd) {
|
||||
// A url() function without unquoted
|
||||
return extractLink(index - 1, ")", content, linksToAdd);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static class ContentChunkInfo implements Comparable<ContentChunkInfo> {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+234
@@ -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}.
|
||||
*
|
||||
* <p>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<String> 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<String> links) {
|
||||
CssLinkParser parser = new CssLinkParser();
|
||||
List<CssLinkParser.Token> 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();
|
||||
}
|
||||
|
||||
}
|
||||
+5
-8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user