Skip to content

Commit 78bbfba

Browse files
authored
Servlerless API protection with annotations (#93607)
This PR makes it so that only REST endpoints that are explicitly annotated with a ServerlessScope are visible when running in serverless mode. Each RestHandler can be given an annotation of either Scope.PUBLIC or Scope.INTERNAL. PUBLIC means that it is visible to everyone. INTERNAL means that it is meant to be visible only to the control plane, and requires a `X-elastic-internal-origin` HTTP header. No annotation means that the RestHandler is not visible at all in Serverless mode. For now, this functionality is only enabled if the `serverless.enabled` node setting is set to `true` (`false` by default), but will be configured instead with the `stateless.enabled` node setting in the future. It is a separate setting in this initial commit because in this commit only two endpoints are enabled, which would make running stateless in a meaningful way impossible. Also note that this PR only annotates `/` and `/favicon.ico`. Follow-up PRs will correctly annotate other endpoints.
1 parent dd3d794 commit 78bbfba

File tree

16 files changed

+364
-31
lines changed

16 files changed

+364
-31
lines changed

docs/changelog/93607.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 93607
2+
summary: Servlerless API protection with annotations
3+
area: Indices APIs
4+
type: enhancement
5+
issues: []

server/src/main/java/org/elasticsearch/action/ActionModule.java

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@
255255
import org.elasticsearch.action.update.UpdateAction;
256256
import org.elasticsearch.client.internal.node.NodeClient;
257257
import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
258+
import org.elasticsearch.cluster.node.DiscoveryNode;
258259
import org.elasticsearch.cluster.node.DiscoveryNodes;
259260
import org.elasticsearch.cluster.service.ClusterService;
260261
import org.elasticsearch.common.NamedRegistry;
@@ -448,6 +449,12 @@
448449
public class ActionModule extends AbstractModule {
449450

450451
private static final Logger logger = LogManager.getLogger(ActionModule.class);
452+
/**
453+
* This RestHandler is used as a placeholder for any routes that are unreachable (i.e. have no ServerlessScope annotation) when
454+
* running in serverless mode. It does nothing, and its handleRequest method is never called. It just provides a way to register the
455+
* routes so that we know they do exist.
456+
*/
457+
private static final RestHandler placeholderRestHandler = (request, channel, client) -> {};
451458

452459
private final Settings settings;
453460
private final IndexNameExpressionResolver indexNameExpressionResolver;
@@ -464,6 +471,7 @@ public class ActionModule extends AbstractModule {
464471
private final RequestValidators<IndicesAliasesRequest> indicesAliasesRequestRequestValidators;
465472
private final ThreadPool threadPool;
466473
private final ReservedClusterStateService reservedClusterStateService;
474+
private final boolean serverlessEnabled;
467475

468476
public ActionModule(
469477
Settings settings,
@@ -488,6 +496,7 @@ public ActionModule(
488496
this.settingsFilter = settingsFilter;
489497
this.actionPlugins = actionPlugins;
490498
this.threadPool = threadPool;
499+
this.serverlessEnabled = DiscoveryNode.isServerless();
491500
actions = setupActions(actionPlugins);
492501
actionFilters = setupActionFilters(actionPlugins);
493502
autoCreateIndex = new AutoCreateIndex(settings, clusterSettings, indexNameExpressionResolver, systemIndices);
@@ -530,7 +539,15 @@ public ActionModule(
530539
actionPlugins.stream().flatMap(p -> p.indicesAliasesRequestValidators().stream()).toList()
531540
);
532541

533-
restController = new RestController(headers, restInterceptor, nodeClient, circuitBreakerService, usageService, tracer);
542+
restController = new RestController(
543+
headers,
544+
restInterceptor,
545+
nodeClient,
546+
circuitBreakerService,
547+
usageService,
548+
tracer,
549+
serverlessEnabled
550+
);
534551
reservedClusterStateService = new ReservedClusterStateService(clusterService, reservedStateHandlers);
535552
}
536553

@@ -730,10 +747,18 @@ private static ActionFilters setupActionFilters(List<ActionPlugin> actionPlugins
730747
public void initRestHandlers(Supplier<DiscoveryNodes> nodesInCluster) {
731748
List<AbstractCatAction> catActions = new ArrayList<>();
732749
Consumer<RestHandler> registerHandler = handler -> {
733-
if (handler instanceof AbstractCatAction) {
734-
catActions.add((AbstractCatAction) handler);
750+
if (shouldKeepRestHandler(handler)) {
751+
if (handler instanceof AbstractCatAction) {
752+
catActions.add((AbstractCatAction) handler);
753+
}
754+
restController.registerHandler(handler);
755+
} else {
756+
/*
757+
* There's no way this handler can be reached, so we just register a placeholder so that requests for it are routed to
758+
* RestController for proper error messages.
759+
*/
760+
handler.routes().forEach(route -> restController.registerHandler(route, placeholderRestHandler));
735761
}
736-
restController.registerHandler(handler);
737762
};
738763
registerHandler.accept(new RestAddVotingConfigExclusionAction());
739764
registerHandler.accept(new RestClearVotingConfigExclusionsAction());
@@ -918,6 +943,16 @@ public void initRestHandlers(Supplier<DiscoveryNodes> nodesInCluster) {
918943
registerHandler.accept(new RestCatAction(catActions));
919944
}
920945

946+
/**
947+
* This method is used to determine whether a RestHandler ought to be kept in memory or not. Returns true if serverless mode is
948+
* disabled, or if there is any ServlerlessScope annotation on the RestHandler.
949+
* @param handler
950+
* @return
951+
*/
952+
private boolean shouldKeepRestHandler(final RestHandler handler) {
953+
return serverlessEnabled == false || handler.getServerlessScope() != null;
954+
}
955+
921956
@Override
922957
protected void configure() {
923958
bind(ActionFilters.class).toInstance(actionFilters);

server/src/main/java/org/elasticsearch/cluster/node/DiscoveryNode.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,16 @@ public static boolean isStateless(final Settings settings) {
6363
}
6464
}
6565

66+
/**
67+
* Check if the serverless feature flag is present and set to {@code true}, indicating that the node is
68+
* part of a serverless deployment.
69+
*
70+
* @return true if the serverless feature flag is present and set
71+
*/
72+
public static boolean isServerless() {
73+
return DiscoveryNodeRole.hasServerlessFeatureFlag();
74+
}
75+
6676
static final String COORDINATING_ONLY = "coordinating_only";
6777
public static final TransportVersion EXTERNAL_ID_VERSION = TransportVersion.V_8_3_0;
6878
public static final Comparator<DiscoveryNode> DISCOVERY_NODE_COMPARATOR = Comparator.comparing(DiscoveryNode::getName)

server/src/main/java/org/elasticsearch/cluster/node/DiscoveryNodeRole.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,19 @@ public class DiscoveryNodeRole implements Comparable<DiscoveryNodeRole> {
4242
USE_STATELESS_FEATURE_FLAG = useStateless;
4343
}
4444

45+
/**
46+
* A feature flag to indicate if serverless is available or not. Defaults to false.
47+
*/
48+
private static final String USE_SERVERLESS_SYSTEM_PROPERTY = "es.serverless";
49+
private static final Boolean USE_SERVERLESS_FEATURE_FLAG;
50+
static {
51+
final Boolean useStateless = Booleans.parseBoolean(System.getProperty(USE_SERVERLESS_SYSTEM_PROPERTY), false);
52+
if (useStateless && Build.CURRENT.isSnapshot() == false) {
53+
throw new IllegalArgumentException("Enabling serverless usage is only supported in snapshot builds");
54+
}
55+
USE_SERVERLESS_FEATURE_FLAG = useStateless;
56+
}
57+
4558
private final String roleName;
4659

4760
/**
@@ -407,6 +420,10 @@ public static boolean hasStatelessFeatureFlag() {
407420
return USE_STATELESS_FEATURE_FLAG;
408421
}
409422

423+
public static boolean hasServerlessFeatureFlag() {
424+
return USE_SERVERLESS_FEATURE_FLAG;
425+
}
426+
410427
private static void ensureNoStatelessFeatureFlag(DiscoveryNodeRole role) {
411428
if (hasStatelessFeatureFlag()) {
412429
throw new IllegalArgumentException("Role [" + role.roleName() + "] is only supported on non-stateless deployments");

server/src/main/java/org/elasticsearch/rest/DeprecationRestHandler.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ public void handleRequest(RestRequest request, RestChannel channel, NodeClient c
9595
handler.handleRequest(request, channel, client);
9696
}
9797

98+
@Override
99+
public RestHandler getConcreteRestHandler() {
100+
return handler.getConcreteRestHandler();
101+
}
102+
98103
@Override
99104
public boolean supportsContentStream() {
100105
return handler.supportsContentStream();

server/src/main/java/org/elasticsearch/rest/RestController.java

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ public class RestController implements HttpServerTransport.Dispatcher {
7070
static final Set<String> SAFELISTED_MEDIA_TYPES = Set.of("application/x-www-form-urlencoded", "multipart/form-data", "text/plain");
7171

7272
static final String ELASTIC_PRODUCT_HTTP_HEADER = "X-elastic-product";
73+
static final String ELASTIC_INTERNAL_ORIGIN_HTTP_HEADER = "X-elastic-internal-origin";
7374
static final String ELASTIC_PRODUCT_HTTP_HEADER_VALUE = "Elasticsearch";
7475
static final Set<String> RESERVED_PATHS = Set.of("/__elb_health__", "/__elb_health__/zk", "/_health", "/_health/zk");
75-
7676
private static final BytesReference FAVICON_RESPONSE;
7777

7878
static {
@@ -97,14 +97,17 @@ public class RestController implements HttpServerTransport.Dispatcher {
9797
private final Set<RestHeaderDefinition> headersToCopy;
9898
private final UsageService usageService;
9999
private final Tracer tracer;
100+
// If true, the ServerlessScope annotations will be enforced
101+
private final boolean serverlessEnabled;
100102

101103
public RestController(
102104
Set<RestHeaderDefinition> headersToCopy,
103105
UnaryOperator<RestHandler> handlerWrapper,
104106
NodeClient client,
105107
CircuitBreakerService circuitBreakerService,
106108
UsageService usageService,
107-
Tracer tracer
109+
Tracer tracer,
110+
boolean serverlessEnabled
108111
) {
109112
this.headersToCopy = headersToCopy;
110113
this.usageService = usageService;
@@ -115,12 +118,8 @@ public RestController(
115118
this.handlerWrapper = handlerWrapper;
116119
this.client = client;
117120
this.circuitBreakerService = circuitBreakerService;
118-
registerHandlerNoWrap(
119-
RestRequest.Method.GET,
120-
"/favicon.ico",
121-
RestApiVersion.current(),
122-
(request, channel, clnt) -> channel.sendResponse(new RestResponse(RestStatus.OK, "image/x-icon", FAVICON_RESPONSE))
123-
);
121+
registerHandlerNoWrap(RestRequest.Method.GET, "/favicon.ico", RestApiVersion.current(), new RestFavIconHandler());
122+
this.serverlessEnabled = serverlessEnabled;
124123
}
125124

126125
/**
@@ -371,6 +370,20 @@ private void dispatchRequest(RestRequest request, RestChannel channel, RestHandl
371370
}
372371
}
373372
RestChannel responseChannel = channel;
373+
if (serverlessEnabled) {
374+
Scope scope = handler.getServerlessScope();
375+
if (Scope.INTERNAL.equals(scope)) {
376+
final String internalOrigin = request.header(ELASTIC_INTERNAL_ORIGIN_HTTP_HEADER);
377+
boolean internalRequest = internalOrigin != null;
378+
if (internalRequest == false) {
379+
handleServerlessRequestToProtectedResource(request.uri(), request.method(), responseChannel);
380+
return;
381+
}
382+
} else if (Scope.PUBLIC.equals(scope) == false) {
383+
handleServerlessRequestToProtectedResource(request.uri(), request.method(), responseChannel);
384+
return;
385+
}
386+
}
374387
try {
375388
if (handler.canTripCircuitBreaker()) {
376389
inFlightRequestsBreaker(circuitBreakerService).addEstimateBytesAndMaybeBreak(contentLength, "<http_request>");
@@ -674,6 +687,21 @@ public static void handleBadRequest(String uri, RestRequest.Method method, RestC
674687
}
675688
}
676689

690+
public static void handleServerlessRequestToProtectedResource(String uri, RestRequest.Method method, RestChannel channel)
691+
throws IOException {
692+
try (XContentBuilder builder = channel.newErrorBuilder()) {
693+
builder.startObject();
694+
{
695+
builder.field(
696+
"error",
697+
"uri [" + uri + "] with method [" + method + "] exists but is not available when running in " + "serverless mode"
698+
);
699+
}
700+
builder.endObject();
701+
channel.sendResponse(new RestResponse(BAD_REQUEST, builder));
702+
}
703+
}
704+
677705
/**
678706
* Get the valid set of HTTP methods for a REST request.
679707
*/
@@ -779,4 +807,12 @@ private static CircuitBreaker inFlightRequestsBreaker(CircuitBreakerService circ
779807
// We always obtain a fresh breaker to reflect changes to the breaker configuration.
780808
return circuitBreakerService.getBreaker(CircuitBreaker.IN_FLIGHT_REQUESTS);
781809
}
810+
811+
@ServerlessScope(Scope.PUBLIC)
812+
private static final class RestFavIconHandler implements RestHandler {
813+
@Override
814+
public void handleRequest(RestRequest request, RestChannel channel, NodeClient client) throws Exception {
815+
channel.sendResponse(new RestResponse(RestStatus.OK, "image/x-icon", FAVICON_RESPONSE));
816+
}
817+
}
782818
}

server/src/main/java/org/elasticsearch/rest/RestHandler.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,26 @@ default boolean supportsContentStream() {
4646
return false;
4747
}
4848

49+
/**
50+
* Returns the concrete RestHandler for this RestHandler. That is, if this is a delegating RestHandler it returns the delegate.
51+
* Otherwise it returns itself.
52+
* @return The underlying RestHandler
53+
*/
54+
default RestHandler getConcreteRestHandler() {
55+
return this;
56+
}
57+
58+
/**
59+
* Returns the serverless Scope of this RestHandler. This is only meaningful when running in a servlerless environment. If a
60+
* RestHandler has no ServerlessScope annotation, then this method returns null, meaning that this RestHandler is not visible at all in
61+
* Serverless mode.
62+
* @return The Scope for this handler, or null if there is no ServerlessScope annotation
63+
*/
64+
default Scope getServerlessScope() {
65+
ServerlessScope serverlessScope = getConcreteRestHandler().getClass().getAnnotation(ServerlessScope.class);
66+
return serverlessScope == null ? null : serverlessScope.value();
67+
}
68+
4969
/**
5070
* Indicates if the RestHandler supports working with pooled buffers. If the request handler will not escape the return
5171
* {@link RestRequest#content()} or any buffers extracted from it then there is no need to make a copies of any pooled buffers in the
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0 and the Server Side Public License, v 1; you may not use this file except
5+
* in compliance with, at your election, the Elastic License 2.0 or the Server
6+
* Side Public License, v 1.
7+
*/
8+
9+
package org.elasticsearch.rest;
10+
11+
public enum Scope {
12+
PUBLIC, // available to all requests
13+
INTERNAL // available only to requests with a X-elastic-internal-origin header
14+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0 and the Server Side Public License, v 1; you may not use this file except
5+
* in compliance with, at your election, the Elastic License 2.0 or the Server
6+
* Side Public License, v 1.
7+
*/
8+
9+
package org.elasticsearch.rest;
10+
11+
import java.lang.annotation.ElementType;
12+
import java.lang.annotation.Retention;
13+
import java.lang.annotation.RetentionPolicy;
14+
import java.lang.annotation.Target;
15+
16+
/**
17+
* This annotation is meant to be applied to RestHandler classes, and is used to determine which RestHandlers are available to requests
18+
* at runtime in Serverless mode. This annotation is unused when not running in serverless mode. If this annotation is not present in a
19+
* RestHandler, then that RestHandler is not available at all in Serverless mode.
20+
*/
21+
@Retention(RetentionPolicy.RUNTIME)
22+
@Target(ElementType.TYPE)
23+
public @interface ServerlessScope {
24+
Scope value();
25+
}

server/src/main/java/org/elasticsearch/rest/action/RestMainAction.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import org.elasticsearch.rest.RestRequest;
1717
import org.elasticsearch.rest.RestResponse;
1818
import org.elasticsearch.rest.RestStatus;
19+
import org.elasticsearch.rest.Scope;
20+
import org.elasticsearch.rest.ServerlessScope;
1921
import org.elasticsearch.xcontent.XContentBuilder;
2022

2123
import java.io.IOException;
@@ -24,6 +26,7 @@
2426
import static org.elasticsearch.rest.RestRequest.Method.GET;
2527
import static org.elasticsearch.rest.RestRequest.Method.HEAD;
2628

29+
@ServerlessScope(Scope.INTERNAL)
2730
public class RestMainAction extends BaseRestHandler {
2831

2932
@Override

0 commit comments

Comments
 (0)