Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ managed-nimbus-jose-jwt = "10.9.1"
managed-owasp-java-html-sanitizer = "20260313.1"
managed-jjwt = "0.13.0"
managed-ojdbc-extensions = "1.1.0" # https://github.com/oracle/ojdbc-extensions/releases
micronaut = "5.1.11"
micronaut = "5.1.12"
micronaut-platform = "5.0.4"
awaitility = "4.3.0"
geb = "8.0.1"
Expand Down
24 changes: 24 additions & 0 deletions security-fetch-metadata/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import io.micronaut.build.TestFramework

plugins {
id("io.micronaut.build.internal.security-module")
}
dependencies {
api(mn.micronaut.http)
api(mnValidation.validation)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my comment on FetchMetadataFilterConfigurationProperties: this api dependency exists solely for a @NotBlank that never gets evaluated. If the annotation goes, this can go with it.

compileOnly(mn.micronaut.http.server)
testAnnotationProcessor(mn.micronaut.inject.java)
testImplementation(mnTest.micronaut.test.junit5)
testImplementation(mn.micronaut.http.client)
testImplementation(mn.micronaut.http.server.netty)
testImplementation(mnSerde.micronaut.serde.jackson)
testRuntimeOnly(mnTest.junit.jupiter.engine)
testRuntimeOnly(mnLogging.logback.classic)
}
tasks.withType<Test> {
useJUnitPlatform()
}
micronautBuild {
binaryCompatibility.enabledAfter("5.4.0")
testFramework = TestFramework.JUNIT6
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* Copyright 2017-2026 original 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 io.micronaut.security.fetchmetadata;

import io.micronaut.context.annotation.Requires;
import io.micronaut.core.annotation.Internal;
import io.micronaut.core.order.Ordered;
import io.micronaut.core.util.StringUtils;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.MutableHttpResponse;
import io.micronaut.http.annotation.RequestFilter;
import io.micronaut.http.annotation.ServerFilter;
import io.micronaut.http.filter.ServerFilterPhase;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;

/**
* Server filter that enforces the configured Fetch Metadata request-isolation policy.
*/
@Requires(property = FetchMetadataFilterConfigurationProperties.PROPERTY_ENABLED, value = StringUtils.TRUE, defaultValue = StringUtils.TRUE)
@Requires(classes = ServerFilter.class)
@ServerFilter("${" + FetchMetadataFilterConfigurationProperties.PREFIX + ".pattern:" + ServerFilter.MATCH_ALL_PATTERN + "}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An empty pattern silently disables the filter.

This reads the raw property placeholder, and nothing in the module injects FetchMetadataFilterConfiguration — so neither setPattern's empty-guard nor the @NotBlank on the field is in the request path.

Set micronaut.security.fetch-metadata.filter.pattern: "" (a plausible typo or a mis-substituted env var) and the placeholder resolves to the empty string: @ServerFilter("") matches nothing, the filter never runs, and there is no warning. Fail-open from a config typo is a rough failure mode for a security filter.

FetchMetadataFilterConfigurationPropertiesTest.usesDocumentedDefaultsAndIgnoresEmptyPatterns asserts the setter guard, which gives false confidence — that code path is never exercised in production.

Injecting the configuration bean and deriving the pattern from it would make the guard real.

@Internal
public final class FetchMetadataFilter implements Ordered {
private static final Logger LOG = LoggerFactory.getLogger(FetchMetadataFilter.class);
private final List<FetchMetadataRule<HttpRequest<?>>> rules;

/**
* @param rules Fetch Metadata rules, in evaluation order
*/
FetchMetadataFilter(List<FetchMetadataRule<HttpRequest<?>>> rules) {
this.rules = List.copyOf(rules);
}

/**
* Runs Fetch Metadata isolation before authentication and authorization filters.
*
* @return an order before the main security filter
*/
@Override
public int getOrder() {
return ServerFilterPhase.SECURITY.before();
}

/**
* Evaluates the request against the configured Fetch Metadata rules.
*
* <p>The first rule that returns {@link FetchMetadataRuleResult#ALLOWED} or
* {@link FetchMetadataRuleResult#REJECTED} decides the result. The filter denies the request
* when every rule returns {@link FetchMetadataRuleResult#UNKNOWN}.</p>
*
* @param request the request to evaluate
* @return {@code null} to continue processing, or a forbidden response to reject the request
*/
@RequestFilter
@Nullable
@Internal
public HttpResponse<?> filterRequest(HttpRequest<?> request) {
for (FetchMetadataRule<HttpRequest<?>> rule : rules) {
FetchMetadataRuleResult result = rule.check(request);
if (result == FetchMetadataRuleResult.ALLOWED) {
if (LOG.isTraceEnabled()) {
LOG.trace("request {} {} approved by rule {}", request.getMethod(), request.getPath(), rule.getClass().getSimpleName());
}
return null; // proceed

} else if (result == FetchMetadataRuleResult.REJECTED) {
if (LOG.isTraceEnabled()) {
LOG.trace("request {} {} rejected by rule {}", request.getMethod(), request.getPath(), rule.getClass().getSimpleName());
}
return forbidden();
}
}
if (LOG.isTraceEnabled()) {
LOG.trace("request {} {} rejected because no Fetch Metadata rule allowed it",
request.getMethod(), request.getPath());
}
return forbidden();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A 403 from a security filter is usually worth more than TRACE. As it stands an operator debugging a blocked client sees an unexplained 403 with nothing in the logs at default levels.

Suggest DEBUG for the rejection paths (keeping TRACE for the approvals), and including the actual Sec-Fetch-Site / -Mode / -Dest values in the message — that's the first thing anyone will want when triaging.

}

private static MutableHttpResponse<Object> forbidden() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rejection is a bare 403 with no body and no extension point. Elsewhere in this repo rejections go through RejectionHandler, so applications can render a page, redirect, or add a header.

Even just a @DefaultImplementation-backed seam here would let applications customize the response without forking the filter.

return HttpResponse.status(HttpStatus.FORBIDDEN);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright 2017-2026 original 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 io.micronaut.security.fetchmetadata;

import io.micronaut.core.util.Toggleable;

/**
* Configuration for the Fetch Metadata request filter.
*
* @since 5.4.0
*/
public interface FetchMetadataFilterConfiguration extends Toggleable {
/**
* Returns the request path pattern to which the Fetch Metadata filter applies.
*
* @return the pattern the {@link FetchMetadataFilter} should match
* @since 5.4.0
*/
String getPattern();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright 2017-2026 original 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 io.micronaut.security.fetchmetadata;

import io.micronaut.context.annotation.ConfigurationProperties;
import io.micronaut.core.annotation.Internal;
import io.micronaut.core.util.StringUtils;
import jakarta.validation.constraints.NotBlank;

/**
* Binds configuration for the Fetch Metadata request filter.
*
* @since 5.4.0
*/
@ConfigurationProperties(FetchMetadataFilterConfigurationProperties.PREFIX)
@Internal
final class FetchMetadataFilterConfigurationProperties implements FetchMetadataFilterConfiguration {
/** Configuration prefix for the Fetch Metadata request filter. */
public static final String PREFIX = "micronaut.security.fetch-metadata.filter";
/** Property that enables or disables the Fetch Metadata request filter. */
public static final String PROPERTY_ENABLED = FetchMetadataFilterConfigurationProperties.PREFIX + ".enabled";
/** Default filter enablement. */
public static final boolean DEFAULT_ENABLED = true;
/** Default server-filter pattern. */
public static final String DEFAULT_PATTERN = "/**";

@NotBlank

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NotBlank is inert here: @ConfigurationProperties beans are only validated when the validation annotation processor is on the classpath, and the module only pulls in api(mnValidation.validation) (the API). Combined with the fact that the filter reads the property placeholder rather than this bean (see my comment on FetchMetadataFilter), this constraint never runs.

Either wire up validation properly or drop the annotation and the jakarta.validation dependency — right now it's an api dependency carried for one annotation that does nothing.

Unrelated nit on line 30: this class is package-private while FetchMetadataRulesConfigurationProperties is public final. Both are @ConfigurationProperties in the same module; worth picking one convention.

private String pattern = DEFAULT_PATTERN;

private boolean enabled = DEFAULT_ENABLED;

/**
* Reports whether the Fetch Metadata request filter is enabled.
*
* @return whether the Fetch Metadata request filter is enabled
* @since 5.4.0
*/
@Override
public boolean isEnabled() {
return this.enabled;
}

@Override
public String getPattern() {
return this.pattern;
}

/**
* Enables or disables the Fetch Metadata request filter.
*
* @param enabled whether the filter is enabled; defaults to {@value #DEFAULT_ENABLED}
* @since 5.4.0
*/
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}

/**
* Sets the request path pattern matched by the Fetch Metadata request filter.
* Requests outside this pattern are not evaluated by this module.
*
* @param pattern the server-filter pattern; defaults to {@value #DEFAULT_PATTERN}
* @since 5.4.0
*/
public void setPattern(String pattern) {
if (StringUtils.isNotEmpty(pattern)) {
this.pattern = pattern;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright 2017-2026 original 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 io.micronaut.security.fetchmetadata;

import io.micronaut.core.order.Ordered;

/**
* Evaluates a request using Fetch Metadata or related request information.
*
* <p>Rules are evaluated in {@link Ordered order}. A rule should return
* {@link FetchMetadataRuleResult#UNKNOWN} when it does not apply so another rule can decide.
* Custom rules can override {@link #getOrder()} to select their position relative to the
* built-in policy rules.</p>
*
* @param <T> request type
* @since 5.4.0
*/
public interface FetchMetadataRule<T> extends Ordered {
/**
* Evaluates a request.
*
* @param request the request to evaluate
* @return the rule result
*/
FetchMetadataRuleResult check(T request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright 2017-2026 original 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 io.micronaut.security.fetchmetadata;

/**
* Result of evaluating a {@link FetchMetadataRule}.
*
* @since 5.4.0
*/
public enum FetchMetadataRuleResult {
/**
* The rule explicitly allows this request.
*/
ALLOWED,

/**
* The rule explicitly rejects this request.
*/
REJECTED,

/**
* The rule has no information to make the determination.
*/
UNKNOWN
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright 2017-2026 original 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 io.micronaut.security.fetchmetadata;

import io.micronaut.http.HttpRequest;
import io.micronaut.http.SecFetch;
import org.jspecify.annotations.Nullable;

/**
* A Fetch Metadata rule for {@link HttpRequest} instances.
*
* @since 5.4.0
*/
public interface HttpRequestFetchMetadataRule extends FetchMetadataRule<HttpRequest<?>> {
/**
* Extracts {@link SecFetch} from the request and evaluates it.
*
* @param request the request to evaluate
* @return the rule result
*/
@Override
default FetchMetadataRuleResult check(HttpRequest<?> request) {
return check(request, request.getSecFetch());
}

/**
* Evaluates the request and its parsed Fetch Metadata headers.
*
* @param request the request to evaluate
* @param secFetch parsed Fetch Metadata, or {@code null} when the required headers are absent or invalid
* @return the rule result
*/
FetchMetadataRuleResult check(HttpRequest<?> request, @Nullable SecFetch secFetch);
}
Loading
Loading