Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ Tool access is independent of Halo content RBAC: the key's exact tool allowlist
is the authorization boundary. Newly installed tools are denied until an
administrator explicitly adds them to a key. Disabled and expired keys are
rejected, and rotating a key invalidates its previous secret immediately.
Each key can optionally restrict access to exact IPv4 or IPv6 addresses and CIDR
ranges. An empty IP allowlist means unrestricted access. Requests from an
unmatched or unknown source are rejected as unauthorized and do not update the
key's last-used time.

IP restrictions use the remote address normalized by Halo's HTTP stack. When
Halo is behind a reverse proxy, configure the proxy and Halo so that untrusted
clients cannot supply or preserve `Forwarded` or `X-Forwarded-*` headers, and
prevent direct access that bypasses the trusted proxy. An IP allowlist is an
additional control, not a replacement for TLS and least-privilege tool access.

Requests carrying an MCP Bearer token are limited to 600 per minute per observed
network source before key validation. This is an overall source-level ceiling and
includes successful requests. Tool calls are additionally limited to 120 per
Expand Down
33 changes: 30 additions & 3 deletions api-docs/openapi/v3_0/mcpV1alpha1Api.json
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,18 @@
}
},
"CreateMcpAccessKeyRequest" : {
"required" : [ "allowedTools", "displayName" ],
"required" : [ "allowedIpRanges", "allowedTools", "displayName" ],
"type" : "object",
"properties" : {
"allowedIpRanges" : {
"uniqueItems" : true,
"type" : "array",
"description" : "Allowed IPv4/IPv6 addresses or CIDR ranges. Empty means unrestricted.",
"items" : {
"type" : "string",
"description" : "Allowed IPv4/IPv6 addresses or CIDR ranges. Empty means unrestricted."
}
},
"allowedTools" : {
"uniqueItems" : true,
"type" : "array",
Expand Down Expand Up @@ -325,9 +334,18 @@
}
},
"McpAccessKey" : {
"required" : [ "allowedTools", "displayName", "enabled", "keyPrefix", "name", "ownerName" ],
"required" : [ "allowedIpRanges", "allowedTools", "displayName", "enabled", "keyPrefix", "name", "ownerName" ],
"type" : "object",
"properties" : {
"allowedIpRanges" : {
"uniqueItems" : true,
"type" : "array",
"description" : "Allowed IPv4/IPv6 addresses or CIDR ranges. Empty means unrestricted.",
"items" : {
"type" : "string",
"description" : "Allowed IPv4/IPv6 addresses or CIDR ranges. Empty means unrestricted."
}
},
"allowedTools" : {
"uniqueItems" : true,
"type" : "array",
Expand Down Expand Up @@ -583,9 +601,18 @@
}
},
"UpdateMcpAccessKeyRequest" : {
"required" : [ "allowedTools", "displayName", "enabled" ],
"required" : [ "allowedIpRanges", "allowedTools", "displayName", "enabled" ],
"type" : "object",
"properties" : {
"allowedIpRanges" : {
"uniqueItems" : true,
"type" : "array",
"description" : "Allowed IPv4/IPv6 addresses or CIDR ranges. Empty means unrestricted.",
"items" : {
"type" : "string",
"description" : "Allowed IPv4/IPv6 addresses or CIDR ranges. Empty means unrestricted."
}
},
"allowedTools" : {
"uniqueItems" : true,
"type" : "array",
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/run/halo/mcpserver/McpAccessKey.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ public static class Spec {
private boolean enabled = true;
private Instant expiresAt;
private Set<String> allowedTools = new LinkedHashSet<>();
@Schema(description = "Allowed IPv4/IPv6 addresses or CIDR ranges. Empty means unrestricted.")
private Set<String> allowedIpRanges = new LinkedHashSet<>();
}

@Data
Expand Down
19 changes: 18 additions & 1 deletion src/main/java/run/halo/mcpserver/McpAccessKeyEndpoint.java
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ private Mono<ServerResponse> create(ServerRequest request) {
tuple.getT1().displayName(),
tuple.getT2(),
tools(tuple.getT1().allowedTools()),
tuple.getT1().allowedIpRanges(),
tuple.getT1().expiresAt()))
.flatMap(created -> ServerResponse.created(URI.create("keys/" + created.accessKey()
.getMetadata()
Expand All @@ -166,11 +167,14 @@ private Mono<ServerResponse> update(ServerRequest request) {
name,
body.displayName(),
tools(body.allowedTools()),
body.allowedIpRanges(),
body.expiresAt(),
body.enabled()))
.flatMap(key -> ServerResponse.ok().bodyValue(view(key)))
.onErrorMap(McpAccessKeyService.AccessKeyNotFoundException.class, error ->
new ResponseStatusException(HttpStatus.NOT_FOUND, error.getMessage(), error));
new ResponseStatusException(HttpStatus.NOT_FOUND, error.getMessage(), error))
.onErrorMap(IllegalArgumentException.class, error ->
new ServerWebInputException(error.getMessage(), null, error));
}

private Mono<ServerResponse> rotate(ServerRequest request) {
Expand Down Expand Up @@ -277,6 +281,7 @@ private static AccessKeyView view(McpAccessKey key) {
spec.isEnabled(),
spec.getExpiresAt(),
spec.getAllowedTools() == null ? Set.of() : Set.copyOf(spec.getAllowedTools()),
spec.getAllowedIpRanges() == null ? Set.of() : Set.copyOf(spec.getAllowedIpRanges()),
status == null ? null : status.getLastUsedAt(),
metadata.getCreationTimestamp(),
metadata.getDeletionTimestamp());
Expand All @@ -291,12 +296,20 @@ public GroupVersion groupVersion() {
record CreateKeyRequest(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Set<String> allowedTools,
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
description = "Allowed IPv4/IPv6 addresses or CIDR ranges. Empty means unrestricted.")
Set<String> allowedIpRanges,
Instant expiresAt) {}

@Schema(name = "UpdateMcpAccessKeyRequest")
record UpdateKeyRequest(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Set<String> allowedTools,
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
description = "Allowed IPv4/IPv6 addresses or CIDR ranges. Empty means unrestricted.")
Set<String> allowedIpRanges,
Instant expiresAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean enabled) {}

Expand All @@ -309,6 +322,10 @@ record AccessKeyView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean enabled,
Instant expiresAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Set<String> allowedTools,
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
description = "Allowed IPv4/IPv6 addresses or CIDR ranges. Empty means unrestricted.")
Set<String> allowedIpRanges,
Instant lastUsedAt,
Instant creationTimestamp,
Instant deletionTimestamp) {}
Expand Down
12 changes: 11 additions & 1 deletion src/main/java/run/halo/mcpserver/McpAccessKeyService.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package run.halo.mcpserver;

import java.net.InetSocketAddress;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
Expand Down Expand Up @@ -51,7 +52,9 @@ Mono<CreatedKey> create(
String displayName,
String ownerName,
Set<String> allowedTools,
Set<String> allowedIpRanges,
Instant expiresAt) {
var normalizedIpRanges = McpIpAllowlist.normalize(allowedIpRanges);
var id = UUID.randomUUID().toString();
var secret = randomSecret();
var token = token(id, secret);
Expand All @@ -68,6 +71,7 @@ Mono<CreatedKey> create(
spec.setEnabled(true);
spec.setExpiresAt(expiresAt);
spec.setAllowedTools(copyTools(allowedTools));
spec.setAllowedIpRanges(normalizedIpRanges);
accessKey.setSpec(spec);
return client.create(accessKey).map(created -> new CreatedKey(created, token));
});
Expand All @@ -77,12 +81,15 @@ Mono<McpAccessKey> update(
String id,
String displayName,
Set<String> allowedTools,
Set<String> allowedIpRanges,
Instant expiresAt,
boolean enabled) {
var normalizedIpRanges = McpIpAllowlist.normalize(allowedIpRanges);
return get(id).flatMap(accessKey -> {
var spec = accessKey.getSpec();
spec.setDisplayName(requireDisplayName(displayName));
spec.setAllowedTools(copyTools(allowedTools));
spec.setAllowedIpRanges(normalizedIpRanges);
spec.setExpiresAt(expiresAt);
spec.setEnabled(enabled);
return client.update(accessKey);
Expand All @@ -108,7 +115,8 @@ Mono<Void> delete(String id) {
return get(id).flatMap(client::delete).then();
}

Mono<McpKeyAuthenticationToken> authenticate(String rawToken) {
Mono<McpKeyAuthenticationToken> authenticate(
String rawToken, InetSocketAddress remoteAddress) {
var parsed = parse(rawToken);
if (parsed == null) {
return Mono.empty();
Expand All @@ -117,6 +125,8 @@ Mono<McpKeyAuthenticationToken> authenticate(String rawToken) {
.filter(this::active)
.flatMap(accessKey -> matches(parsed.secret(), accessKey.getSpec().getKeyHash())
.filter(Boolean::booleanValue)
.filter(ignored -> McpIpAllowlist.allows(
accessKey.getSpec().getAllowedIpRanges(), remoteAddress))
.flatMap(ignored -> touch(accessKey).thenReturn(new McpKeyAuthenticationToken(
parsed.id(),
accessKey.getSpec().getDisplayName(),
Expand Down
103 changes: 103 additions & 0 deletions src/main/java/run/halo/mcpserver/McpIpAllowlist.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package run.halo.mcpserver;

import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
import java.util.LinkedHashSet;
import java.util.Set;
import org.springframework.security.util.matcher.InetAddressMatcher;
import org.springframework.security.util.matcher.InetAddressMatchers;
import org.springframework.util.StringUtils;

final class McpIpAllowlist {

private McpIpAllowlist() {}

static Set<String> normalize(Set<String> ranges) {
var normalized = new LinkedHashSet<String>();
if (ranges == null) {
return normalized;
}
for (var range : ranges) {
if (!StringUtils.hasText(range)) {
continue;
}
var value = range.trim();
try {
compile(value);
} catch (IllegalArgumentException error) {
throw new IllegalArgumentException("Invalid IP address or CIDR: " + value, error);
}
normalized.add(value);
}
return normalized;
}

static boolean allows(Set<String> ranges, InetSocketAddress remoteAddress) {
if (ranges == null || ranges.isEmpty()) {
return true;
}
if (remoteAddress == null) {
return false;
}
try {
var address = remoteAddress.getAddress() == null
? parseNumericAddress(remoteAddress.getHostString())
: remoteAddress.getAddress();
var matchers = ranges.stream()
.map(McpIpAllowlist::compile)
.toList();
for (var matcher : matchers) {
if (matcher.matches(address)) {
return true;
}
}
} catch (IllegalArgumentException error) {
return false;
}
return false;
}

private static CompiledRange compile(String range) {
var matcher = InetAddressMatchers.fromIpAddress(range);
var slashIndex = range.indexOf('/');
var address = slashIndex < 0 ? range : range.substring(0, slashIndex);
var addressLength = parseNumericAddress(address).getAddress().length;
if (slashIndex >= 0) {
validateMask(range.substring(slashIndex + 1), addressLength * Byte.SIZE);
}
return new CompiledRange(addressLength, matcher);
}

private static InetAddress parseNumericAddress(String address) {
var value = address.startsWith("[") && address.endsWith("]")
? address.substring(1, address.length() - 1)
: address;
InetAddressMatchers.fromIpAddress(value);
try {
return InetAddress.getByName(value);
} catch (UnknownHostException error) {
throw new IllegalArgumentException(error);
}
}

private static void validateMask(String mask, int maxBits) {
if (!mask.matches("[0-9]+")) {
throw new IllegalArgumentException("Invalid CIDR mask");
}
try {
if (Integer.parseInt(mask) > maxBits) {
throw new IllegalArgumentException("Invalid CIDR mask");
}
} catch (NumberFormatException error) {
throw new IllegalArgumentException("Invalid CIDR mask", error);
}
}

private record CompiledRange(int addressLength, InetAddressMatcher matcher) {

boolean matches(InetAddress address) {
return address.getAddress().length == addressLength && matcher.matches(address);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
return tooManyRequests(exchange);
}
var rawToken = authorization.substring(BEARER_SCHEME.length());
return accessKeyService.authenticate(rawToken)
return accessKeyService.authenticate(rawToken, exchange.getRequest().getRemoteAddress())
.flatMap(authentication -> {
if (!hasSupportedProtocolVersion(exchange)) {
return badRequest(exchange).thenReturn(true);
Expand Down
31 changes: 31 additions & 0 deletions src/test/java/run/halo/mcpserver/McpAccessKeyEndpointTest.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package run.halo.mcpserver;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.when;

import java.time.Instant;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import run.halo.app.extension.Metadata;
Expand Down Expand Up @@ -39,6 +42,7 @@ void listsDeletionTimestamp() {
key.getSpec().setDisplayName("Test key");
key.getSpec().setKeyPrefix("hmcp_test");
key.getSpec().setOwnerName("admin");
key.getSpec().setAllowedIpRanges(Set.of("203.0.113.0/24"));
when(accessKeyService.list()).thenReturn(Flux.just(key));

WebTestClient.bindToRouterFunction(endpoint.endpoint())
Expand All @@ -49,10 +53,37 @@ void listsDeletionTimestamp() {
.expectStatus()
.isOk()
.expectBody()
.jsonPath("$[0].allowedIpRanges[0]")
.isEqualTo("203.0.113.0/24")
.jsonPath("$[0].deletionTimestamp")
.isEqualTo(deletionTimestamp.toString());
}

@Test
void mapsInvalidIpRangesToBadRequestWhenUpdating() {
when(toolCatalog.availableNames()).thenReturn(reactor.core.publisher.Mono.just(Set.of()));
when(accessKeyService.update(any(), any(), any(), any(), any(), anyBoolean()))
.thenReturn(reactor.core.publisher.Mono.error(
new IllegalArgumentException("Invalid IP address or CIDR: invalid")));

WebTestClient.bindToRouterFunction(endpoint.endpoint())
.build()
.put()
.uri("/keys/test-key")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue("""
{
"displayName": "Test key",
"allowedTools": [],
"allowedIpRanges": ["invalid"],
"enabled": true
}
""")
.exchange()
.expectStatus()
.isBadRequest();
}

@Test
void listsRecentCallsWithFilters() {
var call = new McpRecentCall(
Expand Down
Loading