1
0
mirror of synced 2026-07-08 12:20:02 +00:00

Compare commits

...

30 Commits

Author SHA1 Message Date
github-actions[bot] c552366a78 Release 5.7.13 2024-10-21 17:23:29 +00:00
Steve Riesenberg 7215c72373 Migrate slack notifications to GChat
Issue gh-15503

[skip ci]
2024-10-21 11:24:40 -05:00
Rob Winch 4ce7cde155 Add Firewall for WebFlux
Closes gh-15967
2024-10-21 08:46:13 -05:00
Rob Winch c8342fcfae Use antora-extensions 1.10.0 2024-04-19 14:16:02 -05:00
github-actions[bot] 1b0c4d68da Next development version 2024-03-18 11:38:53 +00:00
github-actions[bot] b38b495630 Release 5.7.12 2024-03-18 11:10:08 +00:00
Marcus Hert Da Coregio 5a7f12f1a9 Check for null Authentication
Closes gh-14715
2024-03-18 06:39:08 -03:00
github-actions[bot] c614422f44 Next development version 2023-09-18 16:10:51 +00:00
github-actions[bot] 3656fa6e0c Release 5.7.11 2023-09-18 15:24:51 +00:00
Rob Winch 5b293d2116 Automate spring-security.xsd
Closes gh-13819
2023-09-14 16:01:50 -05:00
Marcus Da Coregio ff4745a461 Merge branch '5.6.x' into 5.7.x 2023-07-18 09:51:19 -03:00
github-actions[bot] 38a7ffcc9b Next development version 2023-07-17 21:52:14 +00:00
github-actions[bot] d0491c07e9 Next development version 2023-07-17 21:42:28 +00:00
github-actions[bot] 486c5118d2 Release 5.7.10 2023-07-17 21:05:15 +00:00
github-actions[bot] 17d8293be1 Release 5.6.12 2023-07-17 21:00:49 +00:00
Marcus Da Coregio fe9bc26bdc Merge branch '5.6.x' into 5.7.x 2023-07-17 09:13:28 -03:00
Marcus Da Coregio 7813a9ba26 Use default PathPatternParser instance 2023-07-17 09:12:28 -03:00
Marcus Da Coregio dc3cb68c76 Merge branch '5.6.x' into 5.7.x
Closes gh-dry-run
2023-07-14 11:04:01 -03:00
Marcus Da Coregio 56292c9971 Update org.springframework.data to 2021.2.14
Closes gh-13512
2023-07-14 10:52:14 -03:00
Marcus Da Coregio 4fc938555e Update org.springframework to 5.3.29
Closes gh-13511
2023-07-14 10:52:11 -03:00
Marcus Da Coregio e574415244 Update io.projectreactor to 2020.0.34
Closes gh-13509
2023-07-14 10:52:03 -03:00
Marcus Da Coregio c5da886310 Update org.springframework to 5.3.29
Closes gh-13508
2023-07-14 10:42:27 -03:00
Marcus Da Coregio 3bef5fd3ed Update io.projectreactor to 2020.0.34
Closes gh-13505
2023-07-14 10:42:27 -03:00
Steve Riesenberg e7201c48d1 Fix copy/paste from main
Issue gh-13500
2023-07-12 16:04:35 -05:00
Steve Riesenberg 991b398c55 Create release with full docs path
Closes gh-13500
2023-07-12 15:51:44 -05:00
Marcus Da Coregio 488b6ea531 Merge branch '5.6.x' into 5.7.x 2023-06-19 14:18:23 -03:00
github-actions[bot] ef04ea9d68 Next development version 2023-06-19 17:05:13 +00:00
github-actions[bot] b7be4c462c Next development version 2023-06-19 16:55:18 +00:00
Marcus Da Coregio aa8440e1ee Release 5.6.11 2023-06-19 13:23:20 -03:00
github-actions[bot] 6d3f1699c0 Release 5.7.9 2023-06-19 16:09:53 +00:00
30 changed files with 2284 additions and 72 deletions
@@ -9,7 +9,6 @@ on:
workflow_dispatch: # Manual trigger
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
GRADLE_ENTERPRISE_CACHE_USER: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USER }}
GRADLE_ENTERPRISE_CACHE_PASSWORD: ${{ secrets.GRADLE_ENTERPRISE_CACHE_PASSWORD }}
GRADLE_ENTERPRISE_SECRET_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_SECRET_ACCESS_KEY }}
@@ -241,26 +240,11 @@ jobs:
export GRADLE_ENTERPRISE_ACCESS_KEY="$GRADLE_ENTERPRISE_SECRET_ACCESS_KEY"
echo "Tagging and publishing $REPO@$VERSION release on GitHub."
./gradlew createGitHubRelease -PnextVersion=$VERSION -Pbranch=$BRANCH -PcreateRelease=true -PgitHubAccessToken=$TOKEN
- name: Announce Release on Slack
id: spring-security-announcing
uses: slackapi/slack-github-action@v1.19.0
with:
payload: |
{
"text": "spring-security-announcing `${{ env.VERSION }}` is available now",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "spring-security-announcing `${{ env.VERSION }}` is available now"
}
}
]
}
- name: Announce Release on GChat
env:
SLACK_WEBHOOK_URL: ${{ secrets.SPRING_RELEASE_SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
WEBHOOK_URL: ${{ secrets.SPRING_RELEASE_GCHAT_WEBHOOK_URL }}
run: |
curl -X POST '${{ env.WEBHOOK_URL }}' -H 'Content-Type: application/json' -d '{ "text": "spring-security-announcing `${{ env.VERSION }}` is available now" }' || true
- name: Setup git config
run: |
git config user.name 'github-actions[bot]'
@@ -295,20 +279,13 @@ jobs:
distribution: 'adopt'
- name: Schedule next release (if not already scheduled)
run: ./gradlew scheduleNextRelease -PnextVersion=$VERSION -PgitHubAccessToken=$TOKEN
notify_result:
name: Check for failures
needs: [perform_release, perform_post_release]
if: failure()
send-notification:
name: Send Notification
needs: [ perform_release, perform_post_release ]
if: ${{ failure() || cancelled() }}
runs-on: ubuntu-latest
permissions:
actions: read
steps:
- name: Send Slack message
# Workaround while waiting for Gamesight/slack-workflow-status#38 to be fixed
# See https://github.com/Gamesight/slack-workflow-status/issues/38
uses: sjohnr/slack-workflow-status@v1-beta
- name: Send Notification
uses: spring-io/spring-security-release-tools/.github/actions/send-notification@v1
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }}
channel: '#spring-security-ci'
name: 'CI Notifier'
webhook-url: ${{ secrets.SPRING_SECURITY_CI_GCHAT_WEBHOOK_URL }}
+2
View File
@@ -28,3 +28,5 @@ s101plugin.state
!.idea/checkstyle-idea.xml
!.idea/externalDependencies.xml
node_modules
@@ -4,6 +4,7 @@ import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.plugins.JavaPlugin
import org.gradle.api.tasks.bundling.Zip
import org.springframework.gradle.xsd.CreateVersionlessXsdTask
public class SchemaZipPlugin implements Plugin<Project> {
@@ -15,7 +16,10 @@ public class SchemaZipPlugin implements Plugin<Project> {
schemaZip.archiveClassifier = 'schema'
schemaZip.description = "Builds -${schemaZip.archiveClassifier} archive containing all " +
"XSDs for deployment at static.springframework.org/schema."
def versionlessXsd = project.tasks.create("versionlessXsd", CreateVersionlessXsdTask) {
description = "Generates spring-security.xsd"
versionlessXsdFile = project.layout.buildDirectory.file("versionlessXsd/spring-security.xsd")
}
project.rootProject.subprojects.each { module ->
module.getPlugins().withType(JavaPlugin.class).all {
@@ -36,17 +40,14 @@ public class SchemaZipPlugin implements Plugin<Project> {
duplicatesStrategy 'exclude'
from xsdFile.path
}
}
File symlink = module.sourceSets.main.resources.find {
it.path.endsWith('org/springframework/security/config/spring-security.xsd')
}
if (symlink != null) {
schemaZip.into('security') {
duplicatesStrategy 'exclude'
from symlink.path
}
versionlessXsd.getInputFiles().from(xsdFile.path)
}
}
}
schemaZip.into("security") {
from(versionlessXsd.getOutputs())
}
}
}
@@ -16,6 +16,10 @@
package org.springframework.gradle.sagan;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.core.runtime.Assert;
import org.gradle.api.DefaultTask;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.TaskAction;
@@ -25,6 +29,8 @@ import org.springframework.gradle.github.user.User;
public class SaganCreateReleaseTask extends DefaultTask {
private static final Pattern VERSION_PATTERN = Pattern.compile("^([0-9]+)\\.([0-9]+)\\.([0-9]+)(-.+)?$");
@Input
private String gitHubAccessToken;
@Input
@@ -44,9 +50,12 @@ public class SaganCreateReleaseTask extends DefaultTask {
// Antora reference docs URLs for snapshots do not contain -SNAPSHOT
String referenceDocUrl = this.referenceDocUrl;
if (this.version.endsWith("-SNAPSHOT")) {
referenceDocUrl = this.referenceDocUrl
.replace("{version}", this.version)
.replace("-SNAPSHOT", "");
Matcher versionMatcher = VERSION_PATTERN.matcher(this.version);
Assert.isTrue(versionMatcher.matches(), "Version " + this.version + " does not match expected pattern");
String majorVersion = versionMatcher.group(1);
String minorVersion = versionMatcher.group(2);
String majorMinorVersion = String.format("%s.%s-SNAPSHOT", majorVersion, minorVersion);
referenceDocUrl = this.referenceDocUrl.replace("{version}", majorMinorVersion);
}
SaganApi sagan = new SaganApi(user.getLogin(), this.gitHubAccessToken);
@@ -0,0 +1,147 @@
/*
* Copyright 2019-2023 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.gradle.xsd;
import org.gradle.api.DefaultTask;
import org.gradle.api.file.ConfigurableFileCollection;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.tasks.*;
import org.gradle.work.DisableCachingByDefault;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Creates the spring-security.xsd automatically
*
* @author Rob Winch
*/
@DisableCachingByDefault(because = "not worth it")
public abstract class CreateVersionlessXsdTask extends DefaultTask {
@InputFiles
public abstract ConfigurableFileCollection getInputFiles();
@OutputFile
abstract RegularFileProperty getVersionlessXsdFile();
@TaskAction
void createVersionlessXsd() throws IOException {
XsdFileMajorMinorVersion largest = null;
ConfigurableFileCollection inputFiles = getInputFiles();
if (inputFiles.isEmpty()) {
throw new IllegalStateException("No Inputs configured");
}
for (File file : inputFiles) {
XsdFileMajorMinorVersion current = XsdFileMajorMinorVersion.create(file);
if (current == null) {
continue;
}
if (largest == null) {
largest = current;
}
else if (current.getVersion().isGreaterThan(largest.getVersion())) {
largest = current;
}
}
if (largest == null) {
throw new IllegalStateException("Could not create versionless xsd file because no files matching spring-security-<digit>.xsd were found in " + inputFiles.getFiles());
}
Path to = getVersionlessXsdFile().getAsFile().get().toPath();
Path from = largest.getFile().toPath();
Files.copy(from, to, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);
}
static class XsdFileMajorMinorVersion {
private final File file;
private final MajorMinorVersion version;
private XsdFileMajorMinorVersion(File file, MajorMinorVersion version) {
this.file = file;
this.version = version;
}
private static final Pattern FILE_MAJOR_MINOR_VERSION_PATTERN = Pattern.compile("^spring-security-(\\d+)\\.(\\d+)\\.xsd$");
/**
* If matches xsd with major minor version (e.g. spring-security-5.1.xsd returns it, otherwise null
* @param file
* @return
*/
static XsdFileMajorMinorVersion create(File file) {
String fileName = file.getName();
Matcher matcher = FILE_MAJOR_MINOR_VERSION_PATTERN.matcher(fileName);
if (!matcher.find()) {
return null;
}
int major = Integer.parseInt(matcher.group(1));
int minor = Integer.parseInt(matcher.group(2));
MajorMinorVersion version = new MajorMinorVersion(major, minor);
return new XsdFileMajorMinorVersion(file, version);
}
public File getFile() {
return file;
}
public MajorMinorVersion getVersion() {
return version;
}
}
static class MajorMinorVersion {
private final int major;
private final int minor;
MajorMinorVersion(int major, int minor) {
this.major = major;
this.minor = minor;
}
public int getMajor() {
return major;
}
public int getMinor() {
return minor;
}
public boolean isGreaterThan(MajorMinorVersion version) {
if (getMajor() > version.getMajor()) {
return true;
}
if (getMajor() < version.getMajor()) {
return false;
}
if (getMinor() > version.getMinor()) {
return true;
}
if (getMinor() < version.getMinor()) {
return false;
}
// they are equal
return false;
}
}
}
@@ -0,0 +1,94 @@
/*
* Copyright 2019-2023 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.gradle.xsd;
import org.junit.jupiter.api.Test;
import org.springframework.gradle.xsd.CreateVersionlessXsdTask.MajorMinorVersion;
import org.springframework.gradle.xsd.CreateVersionlessXsdTask.XsdFileMajorMinorVersion;
import java.io.File;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
/**
* @author Rob Winch
*/
class CreateVersionlessXsdTaskTests {
@Test
void xsdCreateWhenValid() {
File file = new File("spring-security-2.0.xsd");
XsdFileMajorMinorVersion xsdFile = XsdFileMajorMinorVersion.create(file);
assertThat(xsdFile).isNotNull();
assertThat(xsdFile.getFile()).isEqualTo(file);
assertThat(xsdFile.getVersion().getMajor()).isEqualTo(2);
assertThat(xsdFile.getVersion().getMinor()).isEqualTo(0);
}
@Test
void xsdCreateWhenPatchReleaseThenNull() {
File file = new File("spring-security-2.0.1.xsd");
XsdFileMajorMinorVersion xsdFile = XsdFileMajorMinorVersion.create(file);
assertThat(xsdFile).isNull();
}
@Test
void xsdCreateWhenNotXsdFileThenNull() {
File file = new File("spring-security-2.0.txt");
XsdFileMajorMinorVersion xsdFile = XsdFileMajorMinorVersion.create(file);
assertThat(xsdFile).isNull();
}
@Test
void xsdCreateWhenNotStartWithSpringSecurityThenNull() {
File file = new File("spring-securityNO-2.0.xsd");
XsdFileMajorMinorVersion xsdFile = XsdFileMajorMinorVersion.create(file);
assertThat(xsdFile).isNull();
}
@Test
void isGreaterWhenMajorLarger() {
MajorMinorVersion larger = new MajorMinorVersion(2,0);
MajorMinorVersion smaller = new MajorMinorVersion(1,0);
assertThat(larger.isGreaterThan(smaller)).isTrue();
assertThat(smaller.isGreaterThan(larger)).isFalse();
}
@Test
void isGreaterWhenMinorLarger() {
MajorMinorVersion larger = new MajorMinorVersion(1,1);
MajorMinorVersion smaller = new MajorMinorVersion(1,0);
assertThat(larger.isGreaterThan(smaller)).isTrue();
assertThat(smaller.isGreaterThan(larger)).isFalse();
}
@Test
void isGreaterWhenMajorAndMinorLarger() {
MajorMinorVersion larger = new MajorMinorVersion(2,1);
MajorMinorVersion smaller = new MajorMinorVersion(1,0);
assertThat(larger.isGreaterThan(smaller)).isTrue();
assertThat(smaller.isGreaterThan(larger)).isFalse();
}
@Test
void isGreaterWhenSame() {
MajorMinorVersion first = new MajorMinorVersion(1,0);
MajorMinorVersion second = new MajorMinorVersion(1,0);
assertThat(first.isGreaterThan(second)).isFalse();
assertThat(second.isGreaterThan(first)).isFalse();
}
}
+11
View File
@@ -1,4 +1,5 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.springframework.gradle.xsd.CreateVersionlessXsdTask
apply plugin: 'io.spring.convention.spring-module'
apply plugin: 'trang'
@@ -113,6 +114,16 @@ dependencies {
testRuntimeOnly 'org.hsqldb:hsqldb'
}
def versionlessXsd = project.tasks.create("versionlessXsd", CreateVersionlessXsdTask) {
inputFiles.from(project.sourceSets.main.resources)
versionlessXsdFile = project.layout.buildDirectory.file("versionlessXsd/spring-security.xsd")
}
processResources {
from(versionlessXsd) {
into 'org/springframework/security/config/'
}
}
rncToXsd {
rncDir = file('src/main/resources/org/springframework/security/config/')
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2017 the original author or authors.
* Copyright 2002-2023 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.
@@ -16,6 +16,7 @@
package org.springframework.security.config.web.server;
import java.util.ArrayList;
import java.util.List;
import org.springframework.http.HttpMethod;
@@ -23,6 +24,8 @@ import org.springframework.security.web.server.util.matcher.OrServerWebExchangeM
import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
import org.springframework.web.util.pattern.PathPattern;
import org.springframework.web.util.pattern.PathPatternParser;
/**
* @author Rob Winch
@@ -62,7 +65,8 @@ public abstract class AbstractServerWebExchangeMatcherRegistry<T> {
* {@link ServerWebExchangeMatcher}
*/
public T pathMatchers(HttpMethod method, String... antPatterns) {
return matcher(ServerWebExchangeMatchers.pathMatchers(method, antPatterns));
List<PathPattern> pathPatterns = parsePatterns(antPatterns);
return matcher(ServerWebExchangeMatchers.pathMatchers(method, pathPatterns.toArray(new PathPattern[0])));
}
/**
@@ -74,7 +78,19 @@ public abstract class AbstractServerWebExchangeMatcherRegistry<T> {
* {@link ServerWebExchangeMatcher}
*/
public T pathMatchers(String... antPatterns) {
return matcher(ServerWebExchangeMatchers.pathMatchers(antPatterns));
List<PathPattern> pathPatterns = parsePatterns(antPatterns);
return matcher(ServerWebExchangeMatchers.pathMatchers(pathPatterns.toArray(new PathPattern[0])));
}
private List<PathPattern> parsePatterns(String[] antPatterns) {
PathPatternParser parser = getPathPatternParser();
List<PathPattern> pathPatterns = new ArrayList<>(antPatterns.length);
for (String pattern : antPatterns) {
pattern = parser.initFullPathPattern(pattern);
PathPattern pathPattern = parser.parse(pattern);
pathPatterns.add(pathPattern);
}
return pathPatterns;
}
/**
@@ -96,6 +112,10 @@ public abstract class AbstractServerWebExchangeMatcherRegistry<T> {
*/
protected abstract T registerMatcher(ServerWebExchangeMatcher matcher);
protected PathPatternParser getPathPatternParser() {
return PathPatternParser.defaultInstance;
}
/**
* Associates a {@link ServerWebExchangeMatcher} instances
* @param matcher the {@link ServerWebExchangeMatcher} instance
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2023 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.
@@ -185,9 +185,11 @@ import org.springframework.web.cors.reactive.CorsConfigurationSource;
import org.springframework.web.cors.reactive.CorsProcessor;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.DefaultCorsProcessor;
import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import org.springframework.web.util.pattern.PathPatternParser;
/**
* A {@link ServerHttpSecurity} is similar to Spring Security's {@code HttpSecurity} but
@@ -1557,6 +1559,18 @@ public class ServerHttpSecurity {
return null;
}
private <T> T getBeanOrNull(String beanName, Class<T> requiredClass) {
if (this.context == null) {
return null;
}
try {
return this.context.getBean(beanName, requiredClass);
}
catch (Exception ex) {
return null;
}
}
private <T> String[] getBeanNamesForTypeOrEmpty(Class<T> beanClass) {
if (this.context == null) {
return new String[0];
@@ -1577,6 +1591,8 @@ public class ServerHttpSecurity {
*/
public class AuthorizeExchangeSpec extends AbstractServerWebExchangeMatcherRegistry<AuthorizeExchangeSpec.Access> {
private static final String REQUEST_MAPPING_HANDLER_MAPPING_BEAN_NAME = "requestMappingHandlerMapping";
private DelegatingReactiveAuthorizationManager.Builder managerBldr = DelegatingReactiveAuthorizationManager
.builder();
@@ -1584,6 +1600,8 @@ public class ServerHttpSecurity {
private boolean anyExchangeRegistered;
private PathPatternParser pathPatternParser;
/**
* Allows method chaining to continue configuring the {@link ServerHttpSecurity}
* @return the {@link ServerHttpSecurity} to continue configuring
@@ -1603,6 +1621,22 @@ public class ServerHttpSecurity {
return result;
}
@Override
protected PathPatternParser getPathPatternParser() {
if (this.pathPatternParser != null) {
return this.pathPatternParser;
}
RequestMappingHandlerMapping requestMappingHandlerMapping = getBeanOrNull(
REQUEST_MAPPING_HANDLER_MAPPING_BEAN_NAME, RequestMappingHandlerMapping.class);
if (requestMappingHandlerMapping != null) {
this.pathPatternParser = requestMappingHandlerMapping.getPathPatternParser();
}
if (this.pathPatternParser == null) {
this.pathPatternParser = PathPatternParser.defaultInstance;
}
return this.pathPatternParser;
}
@Override
protected Access registerMatcher(ServerWebExchangeMatcher matcher) {
Assert.state(!this.anyExchangeRegistered, () -> "Cannot register " + matcher
@@ -1 +0,0 @@
spring-security-5.7.xsd
@@ -57,7 +57,7 @@ public class AuthenticatedVoter implements AccessDecisionVoter<Object> {
private AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl();
private boolean isFullyAuthenticated(Authentication authentication) {
return (!this.authenticationTrustResolver.isAnonymous(authentication)
return authentication != null && (!this.authenticationTrustResolver.isAnonymous(authentication)
&& !this.authenticationTrustResolver.isRememberMe(authentication));
}
@@ -59,6 +59,7 @@ public class AuthenticatedVoterTests {
assertThat(AccessDecisionVoter.ACCESS_GRANTED).isEqualTo(voter.vote(createAnonymous(), null, def));
assertThat(AccessDecisionVoter.ACCESS_GRANTED).isEqualTo(voter.vote(createRememberMe(), null, def));
assertThat(AccessDecisionVoter.ACCESS_GRANTED).isEqualTo(voter.vote(createFullyAuthenticated(), null, def));
assertThat(AccessDecisionVoter.ACCESS_DENIED).isEqualTo(voter.vote(null, null, def));
}
@Test
@@ -68,6 +69,7 @@ public class AuthenticatedVoterTests {
assertThat(AccessDecisionVoter.ACCESS_DENIED).isEqualTo(voter.vote(createAnonymous(), null, def));
assertThat(AccessDecisionVoter.ACCESS_DENIED).isEqualTo(voter.vote(createRememberMe(), null, def));
assertThat(AccessDecisionVoter.ACCESS_GRANTED).isEqualTo(voter.vote(createFullyAuthenticated(), null, def));
assertThat(AccessDecisionVoter.ACCESS_DENIED).isEqualTo(voter.vote(null, null, def));
}
@Test
@@ -77,6 +79,7 @@ public class AuthenticatedVoterTests {
assertThat(AccessDecisionVoter.ACCESS_DENIED).isEqualTo(voter.vote(createAnonymous(), null, def));
assertThat(AccessDecisionVoter.ACCESS_GRANTED).isEqualTo(voter.vote(createRememberMe(), null, def));
assertThat(AccessDecisionVoter.ACCESS_GRANTED).isEqualTo(voter.vote(createFullyAuthenticated(), null, def));
assertThat(AccessDecisionVoter.ACCESS_DENIED).isEqualTo(voter.vote(null, null, def));
}
@Test
+2 -2
View File
@@ -8,10 +8,10 @@ javaPlatform {
dependencies {
api platform("org.springframework:spring-framework-bom:$springFrameworkVersion")
api platform("io.projectreactor:reactor-bom:2020.0.33")
api platform("io.projectreactor:reactor-bom:2020.0.34")
api platform("io.rsocket:rsocket-bom:1.1.4")
api platform("org.junit:junit-bom:5.8.2")
api platform("org.springframework.data:spring-data-bom:2021.2.13")
api platform("org.springframework.data:spring-data-bom:2021.2.14")
api platform("org.jetbrains.kotlin:kotlin-bom:$kotlinVersion")
api platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.6.4")
api platform("com.fasterxml.jackson:jackson-bom:2.13.5")
+37
View File
@@ -0,0 +1,37 @@
antora:
extensions:
- require: '@springio/antora-extensions'
root_component_name: 'security'
site:
title: Spring Security
url: https://docs.spring.io/spring-security/reference
robots: allow
git:
ensure_git_suffix: false
content:
sources:
- url: https://github.com/spring-projects/spring-security
branches: [main, '5.{{6..9},{1..9}+({0..9})}.x', '6.+({0..9}).x']
tags: ['5.{{6..9},{1..9}+({0..9})}.{0..99}?(-RC+({0..9}))', '6.+({0..9}).+({0..9})?(-{RC,M}*)','!(5.6.{0..10}*)', '!(5.7.{0..8}*)', '!(5.8.{0..3}?({-RC,-M}+({0..9})))','!(6.0.{0..3}*)','!(6.1.0*)']
start_path: docs
asciidoc:
attributes:
page-stackoverflow-url: https://stackoverflow.com/tags/spring-security
page-related-doc-categories: security
page-related-doc-projects: framework,graphql
hide-uri-scheme: '@'
tabs-sync-option: '@'
extensions:
- '@asciidoctor/tabs'
- '@springio/asciidoctor-extensions'
urls:
latest_version_segment_strategy: redirect:to
latest_version_segment: ''
redirect_facility: httpd
ui:
bundle:
url: https://github.com/spring-io/antora-ui-spring/releases/download/v0.4.11/ui-bundle.zip
snapshot: true
runtime:
log:
failure_level: warn
+1
View File
@@ -147,6 +147,7 @@
*** xref:reactive/exploits/csrf.adoc[CSRF]
*** xref:reactive/exploits/headers.adoc[Headers]
*** xref:reactive/exploits/http.adoc[HTTP Requests]
*** xref:reactive/exploits/firewall.adoc[]
** Integrations
*** xref:reactive/integrations/cors.adoc[CORS]
*** xref:reactive/integrations/rsocket.adoc[RSocket]
@@ -0,0 +1,202 @@
[[webflux-serverwebexchangefirewall]]
= ServerWebExchangeFirewall
There are various ways a request can be created by malicious users that can exploit applications.
Spring Security provides the `ServerWebExchangeFirewall` to allow rejecting requests that look malicious.
The default implementation is `StrictServerWebExchangeFirewall` which rejects malicious requests.
For example a request could contain path-traversal sequences (such as `/../`) or multiple forward slashes (`//`) that could also cause pattern-matches to fail.
Some containers normalize these out before performing the servlet mapping, but others do not.
To protect against issues like these, `WebFilterChainProxy` uses a `ServerWebExchangeFirewall` strategy to check and wrap the request.
By default, un-normalized requests are automatically rejected, and path parameters are removed for matching purposes.
(So, for example, an original request path of `/secure;hack=1/somefile.html;hack=2` is returned as `/secure/somefile.html`.)
It is, therefore, essential that a `WebFilterChainProxy` is used.
In practice, we recommend that you use method security at your service layer, to control access to your application, rather than rely entirely on the use of security constraints defined at the web-application level.
URLs change, and it is difficult to take into account all the possible URLs that an application might support and how requests might be manipulated.
You should restrict yourself to using a few simple patterns that are simple to understand.
Always try to use a "`deny-by-default`" approach, where you have a catch-all wildcard (`/**` or `**`) defined last to deny access.
Security defined at the service layer is much more robust and harder to bypass, so you should always take advantage of Spring Security's method security options.
You can customize the `ServerWebExchangeFirewall` by exposing it as a Bean.
.Allow Matrix Variables
[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Bean
public StrictServerWebExchangeFirewall httpFirewall() {
StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall();
firewall.setAllowSemicolon(true);
return firewall;
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Bean
fun httpFirewall(): StrictServerWebExchangeFirewall {
val firewall = StrictServerWebExchangeFirewall()
firewall.setAllowSemicolon(true)
return firewall
}
----
======
To protect against https://www.owasp.org/index.php/Cross_Site_Tracing[Cross Site Tracing (XST)] and https://www.owasp.org/index.php/Test_HTTP_Methods_(OTG-CONFIG-006)[HTTP Verb Tampering], the `StrictServerWebExchangeFirewall` provides an allowed list of valid HTTP methods that are allowed.
The default valid methods are `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, and `PUT`.
If your application needs to modify the valid methods, you can configure a custom `StrictServerWebExchangeFirewall` bean.
The following example allows only HTTP `GET` and `POST` methods:
.Allow Only GET & POST
[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Bean
public StrictServerWebExchangeFirewall httpFirewall() {
StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall();
firewall.setAllowedHttpMethods(Arrays.asList("GET", "POST"));
return firewall;
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Bean
fun httpFirewall(): StrictServerWebExchangeFirewall {
val firewall = StrictServerWebExchangeFirewall()
firewall.setAllowedHttpMethods(listOf("GET", "POST"))
return firewall
}
----
======
If you must allow any HTTP method (not recommended), you can use `StrictServerWebExchangeFirewall.setUnsafeAllowAnyHttpMethod(true)`.
Doing so entirely disables validation of the HTTP method.
[[webflux-serverwebexchangefirewall-headers-parameters]]
`StrictServerWebExchangeFirewall` also checks header names and values and parameter names.
It requires that each character have a defined code point and not be a control character.
This requirement can be relaxed or adjusted as necessary by using the following methods:
* `StrictServerWebExchangeFirewall#setAllowedHeaderNames(Predicate)`
* `StrictServerWebExchangeFirewall#setAllowedHeaderValues(Predicate)`
* `StrictServerWebExchangeFirewall#setAllowedParameterNames(Predicate)`
[NOTE]
====
Parameter values can be also controlled with `setAllowedParameterValues(Predicate)`.
====
For example, to switch off this check, you can wire your `StrictServerWebExchangeFirewall` with `Predicate` instances that always return `true`:
.Allow Any Header Name, Header Value, and Parameter Name
[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Bean
public StrictServerWebExchangeFirewall httpFirewall() {
StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall();
firewall.setAllowedHeaderNames((header) -> true);
firewall.setAllowedHeaderValues((header) -> true);
firewall.setAllowedParameterNames((parameter) -> true);
return firewall;
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Bean
fun httpFirewall(): StrictServerWebExchangeFirewall {
val firewall = StrictServerWebExchangeFirewall()
firewall.setAllowedHeaderNames { true }
firewall.setAllowedHeaderValues { true }
firewall.setAllowedParameterNames { true }
return firewall
}
----
======
Alternatively, there might be a specific value that you need to allow.
For example, iPhone Xʀ uses a `User-Agent` that includes a character that is not in the ISO-8859-1 charset.
Due to this fact, some application servers parse this value into two separate characters, the latter being an undefined character.
You can address this with the `setAllowedHeaderValues` method:
.Allow Certain User Agents
[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Bean
public StrictServerWebExchangeFirewall httpFirewall() {
StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall();
Pattern allowed = Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]]*");
Pattern userAgent = ...;
firewall.setAllowedHeaderValues((header) -> allowed.matcher(header).matches() || userAgent.matcher(header).matches());
return firewall;
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Bean
fun httpFirewall(): StrictServerWebExchangeFirewall {
val firewall = StrictServerWebExchangeFirewall()
val allowed = Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]]*")
val userAgent = Pattern.compile(...)
firewall.setAllowedHeaderValues { allowed.matcher(it).matches() || userAgent.matcher(it).matches() }
return firewall
}
----
======
In the case of header values, you may instead consider parsing them as UTF-8 at verification time:
.Parse Headers As UTF-8
[tabs]
======
Java::
+
[source,java,role="primary"]
----
firewall.setAllowedHeaderValues((header) -> {
String parsed = new String(header.getBytes(ISO_8859_1), UTF_8);
return allowed.matcher(parsed).matches();
});
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
firewall.setAllowedHeaderValues {
val parsed = String(header.getBytes(ISO_8859_1), UTF_8)
return allowed.matcher(parsed).matches()
}
----
======
+10
View File
@@ -0,0 +1,10 @@
{
"dependencies": {
"antora": "3.2.0-alpha.4",
"@antora/atlas-extension": "1.0.0-alpha.2",
"@antora/collector-extension": "1.0.0-alpha.3",
"@asciidoctor/tabs": "1.0.0-beta.6",
"@springio/antora-extensions": "1.10.0",
"@springio/asciidoctor-extensions": "1.0.0-alpha.10"
}
}
+4 -7
View File
@@ -7,14 +7,11 @@ apply plugin: 'io.spring.convention.docs'
apply plugin: 'java'
antora {
playbook = 'cached-antora-playbook.yml'
playbookProvider {
repository = 'spring-projects/spring-security'
branch = 'docs-build'
path = 'lib/antora/templates/per-branch-antora-playbook.yml'
checkLocalBranch = true
}
options = [clean: true, fetch: !project.gradle.startParameter.offline, stacktrace: true]
environment = [
'BUILD_REFNAME': 'HEAD',
'BUILD_VERSION': project.version,
]
}
tasks.named("generateAntoraYml") {
+2 -2
View File
@@ -1,9 +1,9 @@
aspectjVersion=1.9.19
springJavaformatVersion=0.0.31
springBootVersion=2.4.2
springFrameworkVersion=5.3.28
springFrameworkVersion=5.3.29
openSamlVersion=3.4.6
version=5.7.9-SNAPSHOT
version=5.7.13
kotlinVersion=1.6.21
samplesBranch=5.7.x
org.gradle.jvmargs=-Xmx3g -XX:MaxPermSize=2048m -XX:+HeapDumpOnOutOfMemoryError
@@ -22,6 +22,12 @@ import java.util.List;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.security.web.server.firewall.HttpStatusExchangeRejectedHandler;
import org.springframework.security.web.server.firewall.ServerExchangeRejectedException;
import org.springframework.security.web.server.firewall.ServerExchangeRejectedHandler;
import org.springframework.security.web.server.firewall.ServerWebExchangeFirewall;
import org.springframework.security.web.server.firewall.StrictServerWebExchangeFirewall;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
@@ -38,6 +44,10 @@ public class WebFilterChainProxy implements WebFilter {
private final List<SecurityWebFilterChain> filters;
private ServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall();
private ServerExchangeRejectedHandler exchangeRejectedHandler = new HttpStatusExchangeRejectedHandler();
public WebFilterChainProxy(List<SecurityWebFilterChain> filters) {
this.filters = filters;
}
@@ -48,12 +58,41 @@ public class WebFilterChainProxy implements WebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
return this.firewall.getFirewalledExchange(exchange)
.flatMap((firewalledExchange) -> filterFirewalledExchange(firewalledExchange, chain))
.onErrorResume(ServerExchangeRejectedException.class,
(rejected) -> this.exchangeRejectedHandler.handle(exchange, rejected).then(Mono.empty()));
}
private Mono<Void> filterFirewalledExchange(ServerWebExchange firewalledExchange, WebFilterChain chain) {
return Flux.fromIterable(this.filters)
.filterWhen((securityWebFilterChain) -> securityWebFilterChain.matches(exchange)).next()
.switchIfEmpty(chain.filter(exchange).then(Mono.empty()))
.filterWhen((securityWebFilterChain) -> securityWebFilterChain.matches(firewalledExchange)).next()
.switchIfEmpty(chain.filter(firewalledExchange).then(Mono.empty()))
.flatMap((securityWebFilterChain) -> securityWebFilterChain.getWebFilters().collectList())
.map((filters) -> new FilteringWebHandler(chain::filter, filters)).map(DefaultWebFilterChain::new)
.flatMap((securedChain) -> securedChain.filter(exchange));
.flatMap((securedChain) -> securedChain.filter(firewalledExchange));
}
/**
* Protects the application using the provided
* {@link StrictServerWebExchangeFirewall}.
* @param firewall the {@link StrictServerWebExchangeFirewall} to use. Cannot be null.
* @since 5.7.13
*/
public void setFirewall(ServerWebExchangeFirewall firewall) {
Assert.notNull(firewall, "firewall cannot be null");
this.firewall = firewall;
}
/**
* Handles {@link ServerExchangeRejectedException} when the
* {@link ServerWebExchangeFirewall} rejects the provided {@link ServerWebExchange}.
* @param exchangeRejectedHandler the {@link ServerExchangeRejectedHandler} to use.
* @since 5.7.13
*/
public void setExchangeRejectedHandler(ServerExchangeRejectedHandler exchangeRejectedHandler) {
Assert.notNull(exchangeRejectedHandler, "exchangeRejectedHandler cannot be null");
this.exchangeRejectedHandler = exchangeRejectedHandler;
}
}
@@ -0,0 +1,66 @@
/*
* Copyright 2002-2024 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.security.web.server.firewall;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Mono;
import org.springframework.core.log.LogMessage;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ServerWebExchange;
/**
* A simple implementation of {@link ServerExchangeRejectedHandler} that sends an error
* with configurable status code.
*
* @author Rob Winch
* @since 5.7.13
*/
public class HttpStatusExchangeRejectedHandler implements ServerExchangeRejectedHandler {
private static final Log logger = LogFactory.getLog(HttpStatusExchangeRejectedHandler.class);
private final HttpStatus status;
/**
* Constructs an instance which uses {@code 400} as response code.
*/
public HttpStatusExchangeRejectedHandler() {
this(HttpStatus.BAD_REQUEST);
}
/**
* Constructs an instance which uses a configurable http code as response.
* @param status http status code to use
*/
public HttpStatusExchangeRejectedHandler(HttpStatus status) {
this.status = status;
}
@Override
public Mono<Void> handle(ServerWebExchange exchange,
ServerExchangeRejectedException serverExchangeRejectedException) {
return Mono.fromRunnable(() -> {
logger.debug(
LogMessage.format("Rejecting request due to: %s", serverExchangeRejectedException.getMessage()),
serverExchangeRejectedException);
exchange.getResponse().setStatusCode(this.status);
});
}
}
@@ -0,0 +1,31 @@
/*
* Copyright 2002-2024 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.security.web.server.firewall;
/**
* Thrown when a {@link org.springframework.web.server.ServerWebExchange} is rejected.
*
* @author Rob Winch
* @since 5.7.13
*/
public class ServerExchangeRejectedException extends RuntimeException {
public ServerExchangeRejectedException(String message) {
super(message);
}
}
@@ -0,0 +1,39 @@
/*
* Copyright 2002-2024 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.security.web.server.firewall;
import reactor.core.publisher.Mono;
import org.springframework.web.server.ServerWebExchange;
/**
* Handles {@link ServerExchangeRejectedException} thrown by
* {@link ServerWebExchangeFirewall}.
*
* @author Rob Winch
* @since 5.7.13
*/
public interface ServerExchangeRejectedHandler {
/**
* Handles an request rejected failure.
* @param exchange the {@link ServerWebExchange} that was rejected
* @param serverExchangeRejectedException that caused the invocation
*/
Mono<Void> handle(ServerWebExchange exchange, ServerExchangeRejectedException serverExchangeRejectedException);
}
@@ -0,0 +1,46 @@
/*
* Copyright 2002-2024 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.security.web.server.firewall;
import reactor.core.publisher.Mono;
import org.springframework.web.server.ServerWebExchange;
/**
* Interface which can be used to reject potentially dangerous requests and/or wrap them
* to control their behaviour.
*
* @author Rob Winch
* @since 5.7.13
*/
public interface ServerWebExchangeFirewall {
/**
* An implementation of {@link StrictServerWebExchangeFirewall} that does nothing.
* This is considered insecure and not recommended.
*/
ServerWebExchangeFirewall INSECURE_NOOP = (exchange) -> Mono.just(exchange);
/**
* Get a {@link ServerWebExchange} that has firewall rules applied to it.
* @param exchange the {@link ServerWebExchange} to apply firewall rules to.
* @return the {@link ServerWebExchange} that has firewall rules applied to it.
* @throws ServerExchangeRejectedException when a rule is broken.
*/
Mono<ServerWebExchange> getFirewalledExchange(ServerWebExchange exchange);
}
@@ -0,0 +1,791 @@
/*
* Copyright 2002-2024 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.security.web.server.firewall;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import reactor.core.publisher.Mono;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.util.Assert;
import org.springframework.util.MultiValueMap;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.ServerWebExchangeDecorator;
/**
* <p>
* A strict implementation of {@link ServerWebExchangeFirewall} that rejects any
* suspicious requests with a {@link ServerExchangeRejectedException}.
* </p>
* <p>
* The following rules are applied to the firewall:
* </p>
* <ul>
* <li>Rejects HTTP methods that are not allowed. This specified to block
* <a href="https://www.owasp.org/index.php/Test_HTTP_Methods_(OTG-CONFIG-006)">HTTP Verb
* tampering and XST attacks</a>. See {@link #setAllowedHttpMethods(Collection)}</li>
* <li>Rejects URLs that are not normalized to avoid bypassing security constraints. There
* is no way to disable this as it is considered extremely risky to disable this
* constraint. A few options to allow this behavior is to normalize the request prior to
* the firewall or using
* {@link org.springframework.security.web.firewall.DefaultHttpFirewall} instead. Please
* keep in mind that normalizing the request is fragile and why requests are rejected
* rather than normalized.</li>
* <li>Rejects URLs that contain characters that are not printable ASCII characters. There
* is no way to disable this as it is considered extremely risky to disable this
* constraint.</li>
* <li>Rejects URLs that contain semicolons. See {@link #setAllowSemicolon(boolean)}</li>
* <li>Rejects URLs that contain a URL encoded slash. See
* {@link #setAllowUrlEncodedSlash(boolean)}</li>
* <li>Rejects URLs that contain a backslash. See {@link #setAllowBackSlash(boolean)}</li>
* <li>Rejects URLs that contain a null character. See {@link #setAllowNull(boolean)}</li>
* <li>Rejects URLs that contain a URL encoded percent. See
* {@link #setAllowUrlEncodedPercent(boolean)}</li>
* <li>Rejects hosts that are not allowed. See {@link #setAllowedHostnames(Predicate)}
* </li>
* <li>Reject headers names that are not allowed. See
* {@link #setAllowedHeaderNames(Predicate)}</li>
* <li>Reject headers values that are not allowed. See
* {@link #setAllowedHeaderValues(Predicate)}</li>
* <li>Reject parameter names that are not allowed. See
* {@link #setAllowedParameterNames(Predicate)}</li>
* <li>Reject parameter values that are not allowed. See
* {@link #setAllowedParameterValues(Predicate)}</li>
* </ul>
*
* @author Rob Winch
* @since 5.7.13
*/
public class StrictServerWebExchangeFirewall implements ServerWebExchangeFirewall {
/**
* Used to specify to {@link #setAllowedHttpMethods(Collection)} that any HTTP method
* should be allowed.
*/
private static final Set<HttpMethod> ALLOW_ANY_HTTP_METHOD = Collections.emptySet();
private static final String ENCODED_PERCENT = "%25";
private static final String PERCENT = "%";
private static final List<String> FORBIDDEN_ENCODED_PERIOD = Collections
.unmodifiableList(Arrays.asList("%2e", "%2E"));
private static final List<String> FORBIDDEN_SEMICOLON = Collections
.unmodifiableList(Arrays.asList(";", "%3b", "%3B"));
private static final List<String> FORBIDDEN_FORWARDSLASH = Collections
.unmodifiableList(Arrays.asList("%2f", "%2F"));
private static final List<String> FORBIDDEN_DOUBLE_FORWARDSLASH = Collections
.unmodifiableList(Arrays.asList("//", "%2f%2f", "%2f%2F", "%2F%2f", "%2F%2F"));
private static final List<String> FORBIDDEN_BACKSLASH = Collections
.unmodifiableList(Arrays.asList("\\", "%5c", "%5C"));
private static final List<String> FORBIDDEN_NULL = Collections.unmodifiableList(Arrays.asList("\0", "%00"));
private static final List<String> FORBIDDEN_LF = Collections.unmodifiableList(Arrays.asList("\n", "%0a", "%0A"));
private static final List<String> FORBIDDEN_CR = Collections.unmodifiableList(Arrays.asList("\r", "%0d", "%0D"));
private static final List<String> FORBIDDEN_LINE_SEPARATOR = Collections.unmodifiableList(Arrays.asList("\u2028"));
private static final List<String> FORBIDDEN_PARAGRAPH_SEPARATOR = Collections
.unmodifiableList(Arrays.asList("\u2029"));
private Set<String> encodedUrlBlocklist = new HashSet<>();
private Set<String> decodedUrlBlocklist = new HashSet<>();
private Set<HttpMethod> allowedHttpMethods = createDefaultAllowedHttpMethods();
private Predicate<String> allowedHostnames = (hostname) -> true;
private static final Pattern ASSIGNED_AND_NOT_ISO_CONTROL_PATTERN = Pattern
.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]]*");
private static final Predicate<String> ASSIGNED_AND_NOT_ISO_CONTROL_PREDICATE = (
s) -> ASSIGNED_AND_NOT_ISO_CONTROL_PATTERN.matcher(s).matches();
private static final Pattern HEADER_VALUE_PATTERN = Pattern.compile("[\\p{IsAssigned}&&[[^\\p{IsControl}]||\\t]]*");
private static final Predicate<String> HEADER_VALUE_PREDICATE = (s) -> s == null
|| HEADER_VALUE_PATTERN.matcher(s).matches();
private Predicate<String> allowedHeaderNames = ALLOWED_HEADER_NAMES;
public static final Predicate<String> ALLOWED_HEADER_NAMES = ASSIGNED_AND_NOT_ISO_CONTROL_PREDICATE;
private Predicate<String> allowedHeaderValues = ALLOWED_HEADER_VALUES;
public static final Predicate<String> ALLOWED_HEADER_VALUES = HEADER_VALUE_PREDICATE;
private Predicate<String> allowedParameterNames = ALLOWED_PARAMETER_NAMES;
public static final Predicate<String> ALLOWED_PARAMETER_NAMES = ASSIGNED_AND_NOT_ISO_CONTROL_PREDICATE;
private Predicate<String> allowedParameterValues = ALLOWED_PARAMETER_VALUES;
public static final Predicate<String> ALLOWED_PARAMETER_VALUES = (value) -> true;
public StrictServerWebExchangeFirewall() {
urlBlocklistsAddAll(FORBIDDEN_SEMICOLON);
urlBlocklistsAddAll(FORBIDDEN_FORWARDSLASH);
urlBlocklistsAddAll(FORBIDDEN_DOUBLE_FORWARDSLASH);
urlBlocklistsAddAll(FORBIDDEN_BACKSLASH);
urlBlocklistsAddAll(FORBIDDEN_NULL);
urlBlocklistsAddAll(FORBIDDEN_LF);
urlBlocklistsAddAll(FORBIDDEN_CR);
this.encodedUrlBlocklist.add(ENCODED_PERCENT);
this.encodedUrlBlocklist.addAll(FORBIDDEN_ENCODED_PERIOD);
this.decodedUrlBlocklist.add(PERCENT);
this.decodedUrlBlocklist.addAll(FORBIDDEN_LINE_SEPARATOR);
this.decodedUrlBlocklist.addAll(FORBIDDEN_PARAGRAPH_SEPARATOR);
}
public Set<String> getEncodedUrlBlocklist() {
return this.encodedUrlBlocklist;
}
public Set<String> getDecodedUrlBlocklist() {
return this.decodedUrlBlocklist;
}
@Override
public Mono<ServerWebExchange> getFirewalledExchange(ServerWebExchange exchange) {
return Mono.fromCallable(() -> {
ServerHttpRequest request = exchange.getRequest();
rejectForbiddenHttpMethod(request);
rejectedBlocklistedUrls(request);
rejectedUntrustedHosts(request);
if (!isNormalized(request)) {
throw new ServerExchangeRejectedException(
"The request was rejected because the URL was not normalized");
}
exchange.getResponse().beforeCommit(() -> Mono.fromRunnable(() -> {
ServerHttpResponse response = exchange.getResponse();
HttpHeaders headers = response.getHeaders();
for (Map.Entry<String, List<String>> header : headers.entrySet()) {
String headerName = header.getKey();
List<String> headerValues = header.getValue();
for (String headerValue : headerValues) {
validateCrlf(headerName, headerValue);
}
}
}));
return new StrictFirewallServerWebExchange(exchange);
});
}
private static void validateCrlf(String name, String value) {
Assert.isTrue(!hasCrlf(name) && !hasCrlf(value), () -> "Invalid characters (CR/LF) in header " + name);
}
private static boolean hasCrlf(String value) {
return value != null && (value.indexOf('\n') != -1 || value.indexOf('\r') != -1);
}
/**
* Sets if any HTTP method is allowed. If this set to true, then no validation on the
* HTTP method will be performed. This can open the application up to
* <a href="https://www.owasp.org/index.php/Test_HTTP_Methods_(OTG-CONFIG-006)"> HTTP
* Verb tampering and XST attacks</a>
* @param unsafeAllowAnyHttpMethod if true, disables HTTP method validation, else
* resets back to the defaults. Default is false.
* @since 5.1
* @see #setAllowedHttpMethods(Collection)
*/
public void setUnsafeAllowAnyHttpMethod(boolean unsafeAllowAnyHttpMethod) {
this.allowedHttpMethods = unsafeAllowAnyHttpMethod ? ALLOW_ANY_HTTP_METHOD : createDefaultAllowedHttpMethods();
}
/**
* <p>
* Determines which HTTP methods should be allowed. The default is to allow "DELETE",
* "GET", "HEAD", "OPTIONS", "PATCH", "POST", and "PUT".
* </p>
* @param allowedHttpMethods the case-sensitive collection of HTTP methods that are
* allowed.
* @since 5.1
* @see #setUnsafeAllowAnyHttpMethod(boolean)
*/
public void setAllowedHttpMethods(Collection<HttpMethod> allowedHttpMethods) {
Assert.notNull(allowedHttpMethods, "allowedHttpMethods cannot be null");
this.allowedHttpMethods = (allowedHttpMethods != ALLOW_ANY_HTTP_METHOD) ? new HashSet<>(allowedHttpMethods)
: ALLOW_ANY_HTTP_METHOD;
}
/**
* <p>
* Determines if semicolon is allowed in the URL (i.e. matrix variables). The default
* is to disable this behavior because it is a common way of attempting to perform
* <a href="https://www.owasp.org/index.php/Reflected_File_Download">Reflected File
* Download Attacks</a>. It is also the source of many exploits which bypass URL based
* security.
* </p>
* <p>
* For example, the following CVEs are a subset of the issues related to ambiguities
* in the Servlet Specification on how to treat semicolons that led to CVEs:
* </p>
* <ul>
* <li><a href="https://pivotal.io/security/cve-2016-5007">cve-2016-5007</a></li>
* <li><a href="https://pivotal.io/security/cve-2016-9879">cve-2016-9879</a></li>
* <li><a href="https://pivotal.io/security/cve-2018-1199">cve-2018-1199</a></li>
* </ul>
*
* <p>
* If you are wanting to allow semicolons, please reconsider as it is a very common
* source of security bypasses. A few common reasons users want semicolons and
* alternatives are listed below:
* </p>
* <ul>
* <li>Including the JSESSIONID in the path - You should not include session id (or
* any sensitive information) in a URL as it can lead to leaking. Instead use Cookies.
* </li>
* <li>Matrix Variables - Users wanting to leverage Matrix Variables should consider
* using HTTP parameters instead.</li>
* </ul>
* @param allowSemicolon should semicolons be allowed in the URL. Default is false
*/
public void setAllowSemicolon(boolean allowSemicolon) {
if (allowSemicolon) {
urlBlocklistsRemoveAll(FORBIDDEN_SEMICOLON);
}
else {
urlBlocklistsAddAll(FORBIDDEN_SEMICOLON);
}
}
/**
* <p>
* Determines if a slash "/" that is URL encoded "%2F" should be allowed in the path
* or not. The default is to not allow this behavior because it is a common way to
* bypass URL based security.
* </p>
* <p>
* For example, due to ambiguities in the servlet specification, the value is not
* parsed consistently which results in different values in {@code HttpServletRequest}
* path related values which allow bypassing certain security constraints.
* </p>
* @param allowUrlEncodedSlash should a slash "/" that is URL encoded "%2F" be allowed
* in the path or not. Default is false.
*/
public void setAllowUrlEncodedSlash(boolean allowUrlEncodedSlash) {
if (allowUrlEncodedSlash) {
urlBlocklistsRemoveAll(FORBIDDEN_FORWARDSLASH);
}
else {
urlBlocklistsAddAll(FORBIDDEN_FORWARDSLASH);
}
}
/**
* <p>
* Determines if double slash "//" that is URL encoded "%2F%2F" should be allowed in
* the path or not. The default is to not allow.
* </p>
* @param allowUrlEncodedDoubleSlash should a slash "//" that is URL encoded "%2F%2F"
* be allowed in the path or not. Default is false.
*/
public void setAllowUrlEncodedDoubleSlash(boolean allowUrlEncodedDoubleSlash) {
if (allowUrlEncodedDoubleSlash) {
urlBlocklistsRemoveAll(FORBIDDEN_DOUBLE_FORWARDSLASH);
}
else {
urlBlocklistsAddAll(FORBIDDEN_DOUBLE_FORWARDSLASH);
}
}
/**
* <p>
* Determines if a period "." that is URL encoded "%2E" should be allowed in the path
* or not. The default is to not allow this behavior because it is a frequent source
* of security exploits.
* </p>
* <p>
* For example, due to ambiguities in the servlet specification a URL encoded period
* might lead to bypassing security constraints through a directory traversal attack.
* This is because the path is not parsed consistently which results in different
* values in {@code HttpServletRequest} path related values which allow bypassing
* certain security constraints.
* </p>
* @param allowUrlEncodedPeriod should a period "." that is URL encoded "%2E" be
* allowed in the path or not. Default is false.
*/
public void setAllowUrlEncodedPeriod(boolean allowUrlEncodedPeriod) {
if (allowUrlEncodedPeriod) {
this.encodedUrlBlocklist.removeAll(FORBIDDEN_ENCODED_PERIOD);
}
else {
this.encodedUrlBlocklist.addAll(FORBIDDEN_ENCODED_PERIOD);
}
}
/**
* <p>
* Determines if a backslash "\" or a URL encoded backslash "%5C" should be allowed in
* the path or not. The default is not to allow this behavior because it is a frequent
* source of security exploits.
* </p>
* <p>
* For example, due to ambiguities in the servlet specification a URL encoded period
* might lead to bypassing security constraints through a directory traversal attack.
* This is because the path is not parsed consistently which results in different
* values in {@code HttpServletRequest} path related values which allow bypassing
* certain security constraints.
* </p>
* @param allowBackSlash a backslash "\" or a URL encoded backslash "%5C" be allowed
* in the path or not. Default is false
*/
public void setAllowBackSlash(boolean allowBackSlash) {
if (allowBackSlash) {
urlBlocklistsRemoveAll(FORBIDDEN_BACKSLASH);
}
else {
urlBlocklistsAddAll(FORBIDDEN_BACKSLASH);
}
}
/**
* <p>
* Determines if a null "\0" or a URL encoded nul "%00" should be allowed in the path
* or not. The default is not to allow this behavior because it is a frequent source
* of security exploits.
* </p>
* @param allowNull a null "\0" or a URL encoded null "%00" be allowed in the path or
* not. Default is false
* @since 5.4
*/
public void setAllowNull(boolean allowNull) {
if (allowNull) {
urlBlocklistsRemoveAll(FORBIDDEN_NULL);
}
else {
urlBlocklistsAddAll(FORBIDDEN_NULL);
}
}
/**
* <p>
* Determines if a percent "%" that is URL encoded "%25" should be allowed in the path
* or not. The default is not to allow this behavior because it is a frequent source
* of security exploits.
* </p>
* <p>
* For example, this can lead to exploits that involve double URL encoding that lead
* to bypassing security constraints.
* </p>
* @param allowUrlEncodedPercent if a percent "%" that is URL encoded "%25" should be
* allowed in the path or not. Default is false
*/
public void setAllowUrlEncodedPercent(boolean allowUrlEncodedPercent) {
if (allowUrlEncodedPercent) {
this.encodedUrlBlocklist.remove(ENCODED_PERCENT);
this.decodedUrlBlocklist.remove(PERCENT);
}
else {
this.encodedUrlBlocklist.add(ENCODED_PERCENT);
this.decodedUrlBlocklist.add(PERCENT);
}
}
/**
* Determines if a URL encoded Carriage Return is allowed in the path or not. The
* default is not to allow this behavior because it is a frequent source of security
* exploits.
* @param allowUrlEncodedCarriageReturn if URL encoded Carriage Return is allowed in
* the URL or not. Default is false.
*/
public void setAllowUrlEncodedCarriageReturn(boolean allowUrlEncodedCarriageReturn) {
if (allowUrlEncodedCarriageReturn) {
urlBlocklistsRemoveAll(FORBIDDEN_CR);
}
else {
urlBlocklistsAddAll(FORBIDDEN_CR);
}
}
/**
* Determines if a URL encoded Line Feed is allowed in the path or not. The default is
* not to allow this behavior because it is a frequent source of security exploits.
* @param allowUrlEncodedLineFeed if URL encoded Line Feed is allowed in the URL or
* not. Default is false.
*/
public void setAllowUrlEncodedLineFeed(boolean allowUrlEncodedLineFeed) {
if (allowUrlEncodedLineFeed) {
urlBlocklistsRemoveAll(FORBIDDEN_LF);
}
else {
urlBlocklistsAddAll(FORBIDDEN_LF);
}
}
/**
* Determines if a URL encoded paragraph separator is allowed in the path or not. The
* default is not to allow this behavior because it is a frequent source of security
* exploits.
* @param allowUrlEncodedParagraphSeparator if URL encoded paragraph separator is
* allowed in the URL or not. Default is false.
*/
public void setAllowUrlEncodedParagraphSeparator(boolean allowUrlEncodedParagraphSeparator) {
if (allowUrlEncodedParagraphSeparator) {
this.decodedUrlBlocklist.removeAll(FORBIDDEN_PARAGRAPH_SEPARATOR);
}
else {
this.decodedUrlBlocklist.addAll(FORBIDDEN_PARAGRAPH_SEPARATOR);
}
}
/**
* Determines if a URL encoded line separator is allowed in the path or not. The
* default is not to allow this behavior because it is a frequent source of security
* exploits.
* @param allowUrlEncodedLineSeparator if URL encoded line separator is allowed in the
* URL or not. Default is false.
*/
public void setAllowUrlEncodedLineSeparator(boolean allowUrlEncodedLineSeparator) {
if (allowUrlEncodedLineSeparator) {
this.decodedUrlBlocklist.removeAll(FORBIDDEN_LINE_SEPARATOR);
}
else {
this.decodedUrlBlocklist.addAll(FORBIDDEN_LINE_SEPARATOR);
}
}
/**
* <p>
* Determines which header names should be allowed. The default is to reject header
* names that contain ISO control characters and characters that are not defined.
* </p>
* @param allowedHeaderNames the predicate for testing header names
* @since 5.4
* @see Character#isISOControl(int)
* @see Character#isDefined(int)
*/
public void setAllowedHeaderNames(Predicate<String> allowedHeaderNames) {
Assert.notNull(allowedHeaderNames, "allowedHeaderNames cannot be null");
this.allowedHeaderNames = allowedHeaderNames;
}
/**
* <p>
* Determines which header values should be allowed. The default is to reject header
* values that contain ISO control characters and characters that are not defined.
* </p>
* @param allowedHeaderValues the predicate for testing hostnames
* @since 5.4
* @see Character#isISOControl(int)
* @see Character#isDefined(int)
*/
public void setAllowedHeaderValues(Predicate<String> allowedHeaderValues) {
Assert.notNull(allowedHeaderValues, "allowedHeaderValues cannot be null");
this.allowedHeaderValues = allowedHeaderValues;
}
/**
* Determines which parameter names should be allowed. The default is to reject header
* names that contain ISO control characters and characters that are not defined.
* @param allowedParameterNames the predicate for testing parameter names
* @since 5.4
* @see Character#isISOControl(int)
* @see Character#isDefined(int)
*/
public void setAllowedParameterNames(Predicate<String> allowedParameterNames) {
Assert.notNull(allowedParameterNames, "allowedParameterNames cannot be null");
this.allowedParameterNames = allowedParameterNames;
}
/**
* <p>
* Determines which parameter values should be allowed. The default is to allow any
* parameter value.
* </p>
* @param allowedParameterValues the predicate for testing parameter values
* @since 5.4
*/
public void setAllowedParameterValues(Predicate<String> allowedParameterValues) {
Assert.notNull(allowedParameterValues, "allowedParameterValues cannot be null");
this.allowedParameterValues = allowedParameterValues;
}
/**
* <p>
* Determines which hostnames should be allowed. The default is to allow any hostname.
* </p>
* @param allowedHostnames the predicate for testing hostnames
* @since 5.2
*/
public void setAllowedHostnames(Predicate<String> allowedHostnames) {
Assert.notNull(allowedHostnames, "allowedHostnames cannot be null");
this.allowedHostnames = allowedHostnames;
}
private void urlBlocklistsAddAll(Collection<String> values) {
this.encodedUrlBlocklist.addAll(values);
this.decodedUrlBlocklist.addAll(values);
}
private void urlBlocklistsRemoveAll(Collection<String> values) {
this.encodedUrlBlocklist.removeAll(values);
this.decodedUrlBlocklist.removeAll(values);
}
private void rejectNonPrintableAsciiCharactersInFieldName(String toCheck, String propertyName) {
if (!containsOnlyPrintableAsciiCharacters(toCheck)) {
throw new ServerExchangeRejectedException(String.format(
"The %s was rejected because it can only contain printable ASCII characters.", propertyName));
}
}
private void rejectForbiddenHttpMethod(ServerHttpRequest request) {
if (this.allowedHttpMethods == ALLOW_ANY_HTTP_METHOD) {
return;
}
if (!this.allowedHttpMethods.contains(request.getMethod())) {
throw new ServerExchangeRejectedException(
"The request was rejected because the HTTP method \"" + request.getMethod()
+ "\" was not included within the list of allowed HTTP methods " + this.allowedHttpMethods);
}
}
private void rejectedBlocklistedUrls(ServerHttpRequest request) {
for (String forbidden : this.encodedUrlBlocklist) {
if (encodedUrlContains(request, forbidden)) {
throw new ServerExchangeRejectedException(
"The request was rejected because the URL contained a potentially malicious String \""
+ forbidden + "\"");
}
}
for (String forbidden : this.decodedUrlBlocklist) {
if (decodedUrlContains(request, forbidden)) {
throw new ServerExchangeRejectedException(
"The request was rejected because the URL contained a potentially malicious String \""
+ forbidden + "\"");
}
}
}
private void rejectedUntrustedHosts(ServerHttpRequest request) {
String hostName = request.getURI().getHost();
if (hostName != null && !this.allowedHostnames.test(hostName)) {
throw new ServerExchangeRejectedException(
"The request was rejected because the domain " + hostName + " is untrusted.");
}
}
private static Set<HttpMethod> createDefaultAllowedHttpMethods() {
Set<HttpMethod> result = new HashSet<>();
result.add(HttpMethod.DELETE);
result.add(HttpMethod.GET);
result.add(HttpMethod.HEAD);
result.add(HttpMethod.OPTIONS);
result.add(HttpMethod.PATCH);
result.add(HttpMethod.POST);
result.add(HttpMethod.PUT);
return result;
}
private boolean isNormalized(ServerHttpRequest request) {
if (!isNormalized(request.getPath().value())) {
return false;
}
if (!isNormalized(request.getURI().getRawPath())) {
return false;
}
if (!isNormalized(request.getURI().getPath())) {
return false;
}
return true;
}
private void validateAllowedHeaderName(String headerNames) {
if (!StrictServerWebExchangeFirewall.this.allowedHeaderNames.test(headerNames)) {
throw new ServerExchangeRejectedException(
"The request was rejected because the header name \"" + headerNames + "\" is not allowed.");
}
}
private void validateAllowedHeaderValue(Object key, String value) {
if (!StrictServerWebExchangeFirewall.this.allowedHeaderValues.test(value)) {
throw new ServerExchangeRejectedException("The request was rejected because the header: \"" + key
+ " \" has a value \"" + value + "\" that is not allowed.");
}
}
private void validateAllowedParameterName(String name) {
if (!StrictServerWebExchangeFirewall.this.allowedParameterNames.test(name)) {
throw new ServerExchangeRejectedException(
"The request was rejected because the parameter name \"" + name + "\" is not allowed.");
}
}
private void validateAllowedParameterValue(String name, String value) {
if (!StrictServerWebExchangeFirewall.this.allowedParameterValues.test(value)) {
throw new ServerExchangeRejectedException("The request was rejected because the parameter: \"" + name
+ " \" has a value \"" + value + "\" that is not allowed.");
}
}
private static boolean encodedUrlContains(ServerHttpRequest request, String value) {
if (valueContains(request.getPath().value(), value)) {
return true;
}
return valueContains(request.getURI().getRawPath(), value);
}
private static boolean decodedUrlContains(ServerHttpRequest request, String value) {
return valueContains(request.getURI().getPath(), value);
}
private static boolean containsOnlyPrintableAsciiCharacters(String uri) {
if (uri == null) {
return true;
}
int length = uri.length();
for (int i = 0; i < length; i++) {
char ch = uri.charAt(i);
if (ch < '\u0020' || ch > '\u007e') {
return false;
}
}
return true;
}
private static boolean valueContains(String value, String contains) {
return value != null && value.contains(contains);
}
/**
* Checks whether a path is normalized (doesn't contain path traversal sequences like
* "./", "/../" or "/.")
* @param path the path to test
* @return true if the path doesn't contain any path-traversal character sequences.
*/
private static boolean isNormalized(String path) {
if (path == null) {
return true;
}
for (int i = path.length(); i > 0;) {
int slashIndex = path.lastIndexOf('/', i - 1);
int gap = i - slashIndex;
if (gap == 2 && path.charAt(slashIndex + 1) == '.') {
return false; // ".", "/./" or "/."
}
if (gap == 3 && path.charAt(slashIndex + 1) == '.' && path.charAt(slashIndex + 2) == '.') {
return false;
}
i = slashIndex;
}
return true;
}
private final class StrictFirewallServerWebExchange extends ServerWebExchangeDecorator {
private StrictFirewallServerWebExchange(ServerWebExchange delegate) {
super(delegate);
}
@Override
public ServerHttpRequest getRequest() {
return new StrictFirewallHttpRequest(super.getRequest());
}
private final class StrictFirewallHttpRequest extends ServerHttpRequestDecorator {
private StrictFirewallHttpRequest(ServerHttpRequest delegate) {
super(delegate);
}
@Override
public HttpHeaders getHeaders() {
return new StrictFirewallHttpHeaders(super.getHeaders());
}
@Override
public MultiValueMap<String, String> getQueryParams() {
MultiValueMap<String, String> queryParams = super.getQueryParams();
for (Map.Entry<String, List<String>> paramEntry : queryParams.entrySet()) {
String paramName = paramEntry.getKey();
validateAllowedParameterName(paramName);
for (String paramValue : paramEntry.getValue()) {
validateAllowedParameterValue(paramName, paramValue);
}
}
return queryParams;
}
private final class StrictFirewallHttpHeaders extends HttpHeaders {
private StrictFirewallHttpHeaders(HttpHeaders delegate) {
super(delegate);
}
@Override
public String getFirst(String headerName) {
validateAllowedHeaderName(headerName);
String headerValue = super.getFirst(headerName);
validateAllowedHeaderValue(headerName, headerValue);
return headerValue;
}
@Override
public List<String> get(Object key) {
if (key instanceof String) {
String headerName = (String) key;
validateAllowedHeaderName(headerName);
}
List<String> headerValues = super.get(key);
if (headerValues == null) {
return headerValues;
}
for (String headerValue : headerValues) {
validateAllowedHeaderValue(key, headerValue);
}
return headerValues;
}
@Override
public Set<String> keySet() {
Set<String> headerNames = super.keySet();
for (String headerName : headerNames) {
validateAllowedHeaderName(headerName);
}
return headerNames;
}
}
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2020 the original author or authors.
* Copyright 2002-2023 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.
@@ -42,8 +42,6 @@ public final class PathPatternParserServerWebExchangeMatcher implements ServerWe
private static final Log logger = LogFactory.getLog(PathPatternParserServerWebExchangeMatcher.class);
private static final PathPatternParser DEFAULT_PATTERN_PARSER = new PathPatternParser();
private final PathPattern pattern;
private final HttpMethod method;
@@ -60,7 +58,7 @@ public final class PathPatternParserServerWebExchangeMatcher implements ServerWe
public PathPatternParserServerWebExchangeMatcher(String pattern, HttpMethod method) {
Assert.notNull(pattern, "pattern cannot be null");
this.pattern = DEFAULT_PATTERN_PARSER.parse(pattern);
this.pattern = parse(pattern);
this.method = method;
}
@@ -68,6 +66,12 @@ public final class PathPatternParserServerWebExchangeMatcher implements ServerWe
this(pattern, null);
}
private PathPattern parse(String pattern) {
PathPatternParser parser = PathPatternParser.defaultInstance;
pattern = parser.initFullPathPattern(pattern);
return parser.parse(pattern);
}
@Override
public Mono<MatchResult> matches(ServerWebExchange exchange) {
ServerHttpRequest request = exchange.getRequest();
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2017 the original author or authors.
* Copyright 2002-2023 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.
@@ -23,6 +23,7 @@ import reactor.core.publisher.Mono;
import org.springframework.http.HttpMethod;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.pattern.PathPattern;
/**
* Provides factory methods for creating common {@link ServerWebExchangeMatcher}
@@ -59,6 +60,30 @@ public abstract class ServerWebExchangeMatchers {
return pathMatchers(null, patterns);
}
/**
* Creates a matcher that matches on any of the provided {@link PathPattern}s.
* @param pathPatterns the {@link PathPattern}s to match on
* @return the matcher to use
*/
public static ServerWebExchangeMatcher pathMatchers(PathPattern... pathPatterns) {
return pathMatchers(null, pathPatterns);
}
/**
* Creates a matcher that matches on the specific method and any of the provided
* {@link PathPattern}s.
* @param method the method to match on. If null, any method will be matched.
* @param pathPatterns the {@link PathPattern}s to match on
* @return the matcher to use
*/
public static ServerWebExchangeMatcher pathMatchers(HttpMethod method, PathPattern... pathPatterns) {
List<ServerWebExchangeMatcher> matchers = new ArrayList<>(pathPatterns.length);
for (PathPattern pathPattern : pathPatterns) {
matchers.add(new PathPatternParserServerWebExchangeMatcher(pathPattern, method));
}
return new OrServerWebExchangeMatcher(matchers);
}
/**
* Creates a matcher that will match on any of the provided matchers
* @param matchers the matchers to match on
@@ -23,6 +23,9 @@ import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.server.firewall.ServerExchangeRejectedException;
import org.springframework.security.web.server.firewall.ServerExchangeRejectedHandler;
import org.springframework.security.web.server.firewall.ServerWebExchangeFirewall;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult;
import org.springframework.test.web.reactive.server.WebTestClient;
@@ -30,6 +33,12 @@ import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* @author Rob Winch
* @since 5.0
@@ -47,6 +56,72 @@ public class WebFilterChainProxyTests {
.isNotFound();
}
@Test
void doFilterWhenFirewallThenBadRequest() {
List<WebFilter> filters = Arrays.asList(new Http200WebFilter());
ServerWebExchangeMatcher notMatch = (exchange) -> MatchResult.notMatch();
MatcherSecurityWebFilterChain chain = new MatcherSecurityWebFilterChain(notMatch, filters);
WebFilterChainProxy filter = new WebFilterChainProxy(chain);
WebTestClient.bindToController(new Object()).webFilter(filter).build().get().uri("/invalid;/").exchange()
.expectStatus().isBadRequest();
}
@Test
void doFilterWhenCustomFirewallThenInvoked() {
List<WebFilter> filters = Arrays.asList(new Http200WebFilter());
ServerWebExchangeMatcher notMatch = (exchange) -> MatchResult.notMatch();
MatcherSecurityWebFilterChain chain = new MatcherSecurityWebFilterChain(notMatch, filters);
WebFilterChainProxy filter = new WebFilterChainProxy(chain);
ServerExchangeRejectedHandler handler = mock(ServerExchangeRejectedHandler.class);
ServerWebExchangeFirewall firewall = mock(ServerWebExchangeFirewall.class);
filter.setFirewall(firewall);
filter.setExchangeRejectedHandler(handler);
WebTestClient.bindToController(new Object()).webFilter(filter).build().get().exchange();
verify(firewall).getFirewalledExchange(any());
verifyNoInteractions(handler);
}
@Test
void doFilterWhenCustomExchangeRejectedHandlerThenInvoked() {
List<WebFilter> filters = Arrays.asList(new Http200WebFilter());
ServerWebExchangeMatcher notMatch = (exchange) -> MatchResult.notMatch();
MatcherSecurityWebFilterChain chain = new MatcherSecurityWebFilterChain(notMatch, filters);
WebFilterChainProxy filter = new WebFilterChainProxy(chain);
ServerExchangeRejectedHandler handler = mock(ServerExchangeRejectedHandler.class);
ServerWebExchangeFirewall firewall = mock(ServerWebExchangeFirewall.class);
given(firewall.getFirewalledExchange(any()))
.willReturn(Mono.error(new ServerExchangeRejectedException("Oops")));
filter.setFirewall(firewall);
filter.setExchangeRejectedHandler(handler);
WebTestClient.bindToController(new Object()).webFilter(filter).build().get().exchange();
verify(firewall).getFirewalledExchange(any());
verify(handler).handle(any(), any());
}
@Test
void doFilterWhenDelayedServerExchangeRejectedException() {
List<WebFilter> filters = Arrays.asList(new WebFilter() {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
// simulate a delayed error (e.g. reading parameters)
return Mono.error(new ServerExchangeRejectedException("Ooops"));
}
});
ServerWebExchangeMatcher match = (exchange) -> MatchResult.match();
MatcherSecurityWebFilterChain chain = new MatcherSecurityWebFilterChain(match, filters);
WebFilterChainProxy filter = new WebFilterChainProxy(chain);
ServerExchangeRejectedHandler handler = mock(ServerExchangeRejectedHandler.class);
filter.setExchangeRejectedHandler(handler);
// @formatter:off
WebTestClient.bindToController(new Object())
.webFilter(filter)
.build()
.get()
.exchange();
// @formatter:on
verify(handler).handle(any(), any());
}
static class Http200WebFilter implements WebFilter {
@Override
@@ -0,0 +1,512 @@
/*
* Copyright 2002-2024 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.security.web.server.firewall;
import java.net.URI;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseCookie;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.web.server.ServerWebExchange;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link StrictServerWebExchangeFirewall}.
*
* @author Rob Winch
* @since 5.7.13
*/
class StrictServerWebExchangeFirewallTests {
public String[] unnormalizedPaths = { "http://exploit.example/..", "http://exploit.example/./path/",
"http://exploit.example/path/path/.", "http://exploit.example/path/path//.",
"http://exploit.example/./path/../path//.", "http://exploit.example/./path",
"http://exploit.example/.//path", "http://exploit.example/.", "http://exploit.example//path",
"http://exploit.example//path/path", "http://exploit.example//path//path",
"http://exploit.example/path//path" };
private StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall();
private MockServerHttpRequest.BaseBuilder<?> request = get("/");
@Test
void cookieWhenHasNewLineThenThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> ResponseCookie.from("test", "Something\nhere").build());
}
@Test
void cookieWhenHasLineFeedThenThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> ResponseCookie.from("test", "Something\rhere").build());
}
@Test
void responseHeadersWhenValueHasNewLineThenThrowsException() {
this.request = MockServerHttpRequest.get("/");
ServerWebExchange exchange = getFirewalledExchange();
exchange.getResponse().getHeaders().set("FOO", "new\nline");
assertThatIllegalArgumentException().isThrownBy(() -> exchange.getResponse().setComplete().block());
}
@Test
void responseHeadersWhenValueHasLineFeedThenThrowsException() {
this.request = MockServerHttpRequest.get("/");
ServerWebExchange exchange = getFirewalledExchange();
exchange.getResponse().getHeaders().set("FOO", "line\rfeed");
assertThatIllegalArgumentException().isThrownBy(() -> exchange.getResponse().setComplete().block());
}
@Test
void responseHeadersWhenNameHasNewLineThenThrowsException() {
this.request = MockServerHttpRequest.get("/");
ServerWebExchange exchange = getFirewalledExchange();
exchange.getResponse().getHeaders().set("new\nline", "FOO");
assertThatIllegalArgumentException().isThrownBy(() -> exchange.getResponse().setComplete().block());
}
@Test
void responseHeadersWhenNameHasLineFeedThenThrowsException() {
this.request = MockServerHttpRequest.get("/");
ServerWebExchange exchange = getFirewalledExchange();
exchange.getResponse().getHeaders().set("line\rfeed", "FOO");
assertThatIllegalArgumentException().isThrownBy(() -> exchange.getResponse().setComplete().block());
}
@Test
void getFirewalledExchangeWhenInvalidMethodThenThrowsServerExchangeRejectedException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> MockServerHttpRequest.method(HttpMethod.valueOf("INVALID"), "/"));
}
private ServerWebExchange getFirewalledExchange() {
MockServerWebExchange exchange = MockServerWebExchange.from(this.request.build());
return this.firewall.getFirewalledExchange(exchange).block();
}
private MockServerHttpRequest.BodyBuilder get(String uri) {
URI url = URI.create(uri);
return MockServerHttpRequest.method(HttpMethod.GET, url);
}
// blocks XST attacks
@Test
void getFirewalledExchangeWhenTraceMethodThenThrowsServerExchangeRejectedException() {
this.request = MockServerHttpRequest.method(HttpMethod.TRACE, "/");
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange());
}
@Test
// blocks XST attack if request is forwarded to a Microsoft IIS web server
void getFirewalledExchangeWhenTrackMethodThenThrowsServerExchangeRejectedException() {
this.request = MockServerHttpRequest.method(HttpMethod.TRACE, "/");
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange());
}
@Test
// HTTP methods are case sensitive
void getFirewalledExchangeWhenLowercaseGetThenThrowsServerExchangeRejectedException() {
assertThatIllegalArgumentException().isThrownBy(() -> HttpMethod.valueOf("get"));
}
@Test
void getFirewalledExchangeWhenAllowedThenNoException() {
List<String> allowedMethods = Arrays.asList("DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT");
for (String allowedMethod : allowedMethods) {
this.request = MockServerHttpRequest.method(HttpMethod.valueOf(allowedMethod), "/");
getFirewalledExchange();
}
}
@Test
void getFirewalledExchangeWhenInvalidMethodAndAnyMethodThenNoException() {
this.firewall.setUnsafeAllowAnyHttpMethod(true);
assertThatIllegalArgumentException()
.isThrownBy(() -> MockServerHttpRequest.method(HttpMethod.valueOf("INVALID"), "/"));
}
@Test
void getFirewalledExchangeWhenURINotNormalizedThenThrowsServerExchangeRejectedException() {
for (String path : this.unnormalizedPaths) {
this.request = get(path);
assertThatExceptionOfType(ServerExchangeRejectedException.class)
.describedAs("The path '" + path + "' is not normalized").isThrownBy(() -> getFirewalledExchange());
}
}
@Test
void getFirewalledExchangeWhenSemicolonInRequestUriThenThrowsServerExchangeRejectedException() {
this.request = get("/path;/");
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange());
}
@Test
void getFirewalledExchangeWhenEncodedSemicolonInRequestUriThenThrowsServerExchangeRejectedException() {
this.request = get("/path%3B/");
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange());
}
@Test
void getFirewalledExchangeWhenLowercaseEncodedSemicolonInRequestUriThenThrowsServerExchangeRejectedException() {
this.request = MockServerHttpRequest.method(HttpMethod.GET, URI.create("/path%3b/"));
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange());
}
@Test
void getFirewalledExchangeWhenSemicolonInRequestUriAndAllowSemicolonThenNoException() {
this.firewall.setAllowSemicolon(true);
this.request = get("/path;/");
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenEncodedSemicolonInRequestUriAndAllowSemicolonThenNoException() {
this.firewall.setAllowSemicolon(true);
this.request = get("/path%3B/");
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenLowercaseEncodedSemicolonInRequestUriAndAllowSemicolonThenNoException() {
this.firewall.setAllowSemicolon(true);
this.request = get("/path%3b/");
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenLowercaseEncodedPeriodInThenThrowsServerExchangeRejectedException() {
this.request = get("/%2e/");
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange());
}
@Test
void getFirewalledExchangeWhenContainsLowerboundAsciiThenNoException() {
this.request = get("/%20");
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenContainsUpperboundAsciiThenNoException() {
this.request = get("/~");
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenJapaneseCharacterThenNoException() {
// FIXME: .method(HttpMethod.GET to .get and similar methods
this.request = get("/\u3042");
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenContainsEncodedNullThenException() {
this.request = get("/something%00/");
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange());
}
@Test
void getFirewalledExchangeWhenContainsLowercaseEncodedLineFeedThenException() {
this.request = get("/something%0a/");
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange());
}
@Test
void getFirewalledExchangeWhenContainsUppercaseEncodedLineFeedThenException() {
this.request = get("/something%0A/");
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange());
}
@Test
void getFirewalledExchangeWhenContainsLowercaseEncodedCarriageReturnThenException() {
this.request = get("/something%0d/");
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange());
}
@Test
void getFirewalledExchangeWhenContainsUppercaseEncodedCarriageReturnThenException() {
this.request = get("/something%0D/");
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange());
}
@Test
void getFirewalledExchangeWhenContainsLowercaseEncodedLineFeedAndAllowedThenNoException() {
this.firewall.setAllowUrlEncodedLineFeed(true);
this.request = get("/something%0a/");
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenContainsUppercaseEncodedLineFeedAndAllowedThenNoException() {
this.firewall.setAllowUrlEncodedLineFeed(true);
this.request = get("/something%0A/");
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenContainsLowercaseEncodedCarriageReturnAndAllowedThenNoException() {
this.firewall.setAllowUrlEncodedCarriageReturn(true);
this.request = get("/something%0d/");
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenContainsUppercaseEncodedCarriageReturnAndAllowedThenNoException() {
this.firewall.setAllowUrlEncodedCarriageReturn(true);
this.request = get("/something%0D/");
getFirewalledExchange();
}
/**
* On WebSphere 8.5 a URL like /context-root/a/b;%2f1/c can bypass a rule on /a/b/c
* because the pathInfo is /a/b;/1/c which ends up being /a/b/1/c while Spring MVC
* will strip the ; content from requestURI before the path is URL decoded.
*/
@Test
void getFirewalledExchangeWhenLowercaseEncodedPathThenException() {
this.request = get("/context-root/a/b;%2f1/c");
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange());
}
@Test
void getFirewalledExchangeWhenUppercaseEncodedPathThenException() {
this.request = get("/context-root/a/b;%2F1/c");
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange());
}
@Test
void getFirewalledExchangeWhenAllowUrlEncodedSlashAndLowercaseEncodedPathThenNoException() {
this.firewall.setAllowUrlEncodedSlash(true);
this.firewall.setAllowSemicolon(true);
this.request = get("/context-root/a/b;%2f1/c");
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenAllowUrlEncodedSlashAndUppercaseEncodedPathThenNoException() {
this.firewall.setAllowUrlEncodedSlash(true);
this.firewall.setAllowSemicolon(true);
this.request = get("/context-root/a/b;%2F1/c");
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenAllowUrlLowerCaseEncodedDoubleSlashThenNoException() {
this.firewall.setAllowUrlEncodedSlash(true);
this.firewall.setAllowUrlEncodedDoubleSlash(true);
this.request = get("/context-root/a/b%2f%2fc");
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenAllowUrlUpperCaseEncodedDoubleSlashThenNoException() {
this.firewall.setAllowUrlEncodedSlash(true);
this.firewall.setAllowUrlEncodedDoubleSlash(true);
this.request = get("/context-root/a/b%2F%2Fc");
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenAllowUrlLowerCaseAndUpperCaseEncodedDoubleSlashThenNoException() {
this.firewall.setAllowUrlEncodedSlash(true);
this.firewall.setAllowUrlEncodedDoubleSlash(true);
this.request = get("/context-root/a/b%2f%2Fc");
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenAllowUrlUpperCaseAndLowerCaseEncodedDoubleSlashThenNoException() {
this.firewall.setAllowUrlEncodedSlash(true);
this.firewall.setAllowUrlEncodedDoubleSlash(true);
this.request = get("/context-root/a/b%2F%2fc");
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenRemoveFromUpperCaseEncodedUrlBlocklistThenNoException() {
this.firewall.setAllowUrlEncodedSlash(true);
this.request = get("/context-root/a/b%2Fc");
this.firewall.getEncodedUrlBlocklist().removeAll(Arrays.asList("%2F%2F"));
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenRemoveFromDecodedUrlBlocklistThenNoException() {
this.request = get("/a/b%2F%2Fc");
this.firewall.getDecodedUrlBlocklist().removeAll(Arrays.asList("//"));
this.firewall.getEncodedUrlBlocklist().removeAll(Arrays.asList("%2F%2F"));
this.firewall.getEncodedUrlBlocklist().removeAll(Arrays.asList("%2F"));
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenTrustedDomainThenNoException() {
this.request.header("Host", "example.org");
this.firewall.setAllowedHostnames((hostname) -> hostname.equals("example.org"));
getFirewalledExchange();
}
@Test
void getFirewalledExchangeWhenUntrustedDomainThenException() {
this.request = get("https://example.org");
this.firewall.setAllowedHostnames((hostname) -> hostname.equals("myexample.org"));
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange());
}
@Test
void getFirewalledExchangeGetHeaderWhenNotAllowedHeaderNameThenException() {
this.firewall.setAllowedHeaderNames((name) -> !name.equals("bad name"));
ServerWebExchange exchange = getFirewalledExchange();
HttpHeaders headers = exchange.getRequest().getHeaders();
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("bad name"));
}
@Test
void getFirewalledExchangeWhenHeaderNameNotAllowedWithAugmentedHeaderNamesThenException() {
this.firewall.setAllowedHeaderNames(
StrictServerWebExchangeFirewall.ALLOWED_HEADER_NAMES.and((name) -> !name.equals("bad name")));
ServerWebExchange exchange = getFirewalledExchange();
HttpHeaders headers = exchange.getRequest().getHeaders();
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.getFirst("bad name"));
}
@Test
void getFirewalledExchangeGetHeaderWhenNotAllowedHeaderValueThenException() {
this.request.header("good name", "bad value");
this.firewall.setAllowedHeaderValues((value) -> !value.equals("bad value"));
ServerWebExchange exchange = getFirewalledExchange();
HttpHeaders headers = exchange.getRequest().getHeaders();
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("good name"));
}
@Test
void getFirewalledExchangeWhenHeaderValueNotAllowedWithAugmentedHeaderValuesThenException() {
this.request.header("good name", "bad value");
this.firewall.setAllowedHeaderValues(
StrictServerWebExchangeFirewall.ALLOWED_HEADER_VALUES.and((value) -> !value.equals("bad value")));
ServerWebExchange exchange = getFirewalledExchange();
HttpHeaders headers = exchange.getRequest().getHeaders();
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("good name"));
}
@Test
void getFirewalledExchangeGetDateHeaderWhenControlCharacterInHeaderNameThenException() {
this.request.header("Bad\0Name", "some value");
ServerWebExchange exchange = getFirewalledExchange();
HttpHeaders headers = exchange.getRequest().getHeaders();
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("Bad\0Name"));
}
@Test
void getFirewalledExchangeGetHeaderWhenUndefinedCharacterInHeaderNameThenException() {
this.request.header("Bad\uFFFEName", "some value");
ServerWebExchange exchange = getFirewalledExchange();
HttpHeaders headers = exchange.getRequest().getHeaders();
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("Bad\uFFFEName"));
}
@Test
void getFirewalledExchangeGetHeadersWhenControlCharacterInHeaderNameThenException() {
this.request.header("Bad\0Name", "some value");
ServerWebExchange exchange = getFirewalledExchange();
HttpHeaders headers = exchange.getRequest().getHeaders();
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("Bad\0Name"));
}
@Test
void getFirewalledExchangeGetHeaderNamesWhenControlCharacterInHeaderNameThenException() {
this.request.header("Bad\0Name", "some value");
ServerWebExchange exchange = getFirewalledExchange();
HttpHeaders headers = exchange.getRequest().getHeaders();
assertThatExceptionOfType(ServerExchangeRejectedException.class)
.isThrownBy(() -> headers.keySet().iterator().next());
}
@Test
void getFirewalledExchangeGetHeaderWhenControlCharacterInHeaderValueThenException() {
this.request.header("Something", "bad\0value");
ServerWebExchange exchange = getFirewalledExchange();
HttpHeaders headers = exchange.getRequest().getHeaders();
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("Something"));
}
@Test
void getFirewalledExchangeGetHeaderWhenHorizontalTabInHeaderValueThenNoException() {
this.request.header("Something", "tab\tvalue");
ServerWebExchange exchange = getFirewalledExchange();
HttpHeaders headers = exchange.getRequest().getHeaders();
assertThat(headers.getFirst("Something")).isEqualTo("tab\tvalue");
}
@Test
void getFirewalledExchangeGetHeaderWhenUndefinedCharacterInHeaderValueThenException() {
this.request.header("Something", "bad\uFFFEvalue");
ServerWebExchange exchange = getFirewalledExchange();
HttpHeaders headers = exchange.getRequest().getHeaders();
assertThatExceptionOfType(ServerExchangeRejectedException.class)
.isThrownBy(() -> headers.getFirst("Something"));
}
@Test
void getFirewalledExchangeGetHeadersWhenControlCharacterInHeaderValueThenException() {
this.request.header("Something", "bad\0value");
ServerWebExchange exchange = getFirewalledExchange();
HttpHeaders headers = exchange.getRequest().getHeaders();
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("Something"));
}
@Test
void getFirewalledExchangeGetParameterWhenControlCharacterInParameterNameThenException() {
this.request.queryParam("Bad\0Name", "some value");
ServerWebExchange exchange = getFirewalledExchange();
ServerHttpRequest request = exchange.getRequest();
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(request::getQueryParams);
}
@Test
void getFirewalledExchangeGetParameterValuesWhenNotAllowedInParameterValueThenException() {
this.firewall.setAllowedParameterValues((value) -> !value.equals("bad value"));
this.request.queryParam("Something", "bad value");
ServerWebExchange exchange = getFirewalledExchange();
ServerHttpRequest request = exchange.getRequest();
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> request.getQueryParams());
}
@Test
void getFirewalledExchangeGetParameterValuesWhenNotAllowedInParameterNameThenException() {
this.firewall.setAllowedParameterNames((value) -> !value.equals("bad name"));
this.request.queryParam("bad name", "good value");
ServerWebExchange exchange = getFirewalledExchange();
ServerHttpRequest request = exchange.getRequest();
assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> request.getQueryParams());
}
// gh-9598
@Test
void getFirewalledExchangeGetHeaderWhenNameIsNullThenNull() {
ServerWebExchange exchange = getFirewalledExchange();
assertThat(exchange.getRequest().getHeaders().get(null)).isNull();
}
}
@@ -0,0 +1,40 @@
/*
* Copyright 2002-2023 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.security.web.server.util.matcher;
import org.junit.jupiter.api.Test;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link PathPatternParserServerWebExchangeMatcher}
*
* @author Marcus da Coregio
*/
class PathPatternParserServerWebExchangeMatcherTests {
@Test
void matchesWhenConfiguredWithNoTrailingSlashAndPathContainsSlashThenMatches() {
PathPatternParserServerWebExchangeMatcher matcher = new PathPatternParserServerWebExchangeMatcher("user/**");
MockServerHttpRequest request = MockServerHttpRequest.get("/user/test").build();
assertThat(matcher.matches(MockServerWebExchange.from(request)).block().isMatch()).isTrue();
}
}