Skip to content

Conversation

@Kehrlann
Copy link
Contributor

@Kehrlann Kehrlann commented Feb 4, 2026

Fixes #695 . Updates conformance tests.

Contents

Add Origin header validation for HTTP-based MCP server transports so servers can restrict which origins can connect (e.g. browser or cross-origin clients).

Introduces new ServerTransportSecurityValidator interface, to validate transport-level security. Default behavior stays permissive: transports use ServerTransportSecurityValidator.NOOP unless a validator is set via the builder.

The provided implementation is DefaultServerTransportSecurityValidator and only validates the Origin header, for now.

New APIs: example usage

HttpServletStreamableServerTransportProvider.builder()
// ...
.securityValidator(
    DefaultServerTransportSecurityValidator.builder()
        .allowedOrigin("http://localhost:*")
        .build())
// ...

Points of interest

Integration tests use the new JUnit 6 @ParameterizedClass API, which is another way of implementation "abstract base classes" for tests.

- Fixes #695
- Does not implement Host header validation yet

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
@Kehrlann Kehrlann requested review from chemicL and tzolov and removed request for chemicL February 4, 2026 20:19
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
@Kehrlann Kehrlann force-pushed the dgarnier/validate-origin-header branch from e5624d4 to 68ed795 Compare February 4, 2026 20:20
@Kehrlann Kehrlann requested a review from chemicL February 4, 2026 20:23
@Kehrlann Kehrlann marked this pull request as ready for review February 4, 2026 20:23
Copy link
Contributor

@tzolov tzolov left a comment

Choose a reason for hiding this comment

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

LGTM. Great work @Kehrlann !


@Test
void differentSchemeWithWildcard() {
var headers = originHeader("https://localhost:3000");
Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't the port be 8080 to ensure that it fails because of the schema?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's using the the wildcard validator, so the port does not matter:

private final DefaultServerTransportSecurityValidator wildcardValidator =
	DefaultServerTransportSecurityValidator
		.builder()
		.allowedOrigin("http://localhost:*")
		.build();

@Kehrlann Kehrlann merged commit 5ed6063 into main Feb 5, 2026
6 checks passed
@Kehrlann Kehrlann deleted the dgarnier/validate-origin-header branch February 5, 2026 09:20
Copy link
Member

@chemicL chemicL left a comment

Choose a reason for hiding this comment

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

Thank you @Kehrlann for taking care of this! I added a few comments post merge. Perhaps eliminating the duplicated code can be addressed as a follow-up. Great job overall!


}

throw INVALID_ORIGIN;
Copy link
Member

Choose a reason for hiding this comment

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

Due to the exception being a static, the stack trace is inaccurate with regards to where it is raised. I understand the optimization, however it might make it harder to investigate where the issue happened. Perhaps we can create an instance each time it's raised?

* @param allowedOrigins List of allowed origin patterns. Supports exact matches
* (e.g., "http://example.com:8080") and wildcard ports (e.g., "http://example.com:*")
*/
public DefaultServerTransportSecurityValidator(List<String> allowedOrigins) {
Copy link
Member

Choose a reason for hiding this comment

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

There are two ways to create the validator - either via a constructor or via the builder. I'd vouch for a single version. The builder makes sense with no inheritance, but since users are encouraged to customize by inheriting from this class, perhaps we can skip the builder?

Comment on lines +333 to +341
try {
Map<String, List<String>> headers = extractHeaders(request);
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
return;
}

Copy link
Member

Choose a reason for hiding this comment

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

This bit is duplicated in doGet, we can create a helper method for it perhaps?

Comment on lines +138 to +145
try {
Map<String, List<String>> headers = extractHeaders(request);
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
return;
}
Copy link
Member

Choose a reason for hiding this comment

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

This code is duplicated across three implementations, perhaps we can have a centralized static utility for it?

* @param request The HTTP servlet request
* @return A map of header names to their values
*/
private Map<String, List<String>> extractHeaders(HttpServletRequest request) {
Copy link
Member

Choose a reason for hiding this comment

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

This private method is also duplicated across implementations, another candidate for sharing.

Comment on lines +399 to +406
try {
Map<String, List<String>> headers = extractHeaders(request);
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
return;
}
Copy link
Member

Choose a reason for hiding this comment

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

Duplicated :)

Comment on lines +571 to +578
try {
Map<String, List<String>> headers = extractHeaders(request);
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
return;
}
Copy link
Member

Choose a reason for hiding this comment

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

Another duplicate that can reuse a shared util.

* @param request The HTTP servlet request
* @return A map of header names to their values
*/
private Map<String, List<String>> extractHeaders(HttpServletRequest request) {
Copy link
Member

Choose a reason for hiding this comment

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

Duplicate

* All transport types we want to test. We use a {@link MethodSource} rather than a
* {@link org.junit.jupiter.params.provider.ValueSource} to provide a readable name.
*/
static Stream<Arguments> transports() {
Copy link
Member

Choose a reason for hiding this comment

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

Nice!

Comment on lines +353 to +359
try {
Map<String, List<String>> headers = request.headers().asHttpHeaders();
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage());
}
Copy link
Member

Choose a reason for hiding this comment

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

This can be unified perhaps too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Return HTTP 403 when Origin header is invalid

3 participants