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
5 changes: 5 additions & 0 deletions serve-cors/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
<artifactId>serve-foundation</artifactId>
</dependency>

<dependency>
<groupId>build.base</groupId>
<artifactId>base-logging</artifactId>
</dependency>

<!-- Test Dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
Expand Down
11 changes: 10 additions & 1 deletion serve-cors/src/main/java/build/serve/cors/CorsMiddleware.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
*/
package build.serve.cors;

import build.base.logging.Logger;
import build.serve.foundation.Exchange;
import build.serve.foundation.Handler;
import build.serve.foundation.middleware.Middleware;
Expand All @@ -41,6 +42,8 @@
*/
public final class CorsMiddleware implements Middleware {

private static final Logger LOGGER = Logger.get(CorsMiddleware.class);

private final Set<String> allowedOrigins;
private final boolean anyOrigin;
private final Set<String> allowedMethods;
Expand Down Expand Up @@ -71,11 +74,17 @@ private CorsMiddleware(final Builder builder) {
/**
* Creates a permissive {@link CorsMiddleware} that allows all origins, methods, and headers.
* <p>
* Intended for development use only.
* <strong>Development use only.</strong> Do not use in production — this permits every origin,
* method, and header with no restrictions.
*
* @return a permissive {@link CorsMiddleware}
* @deprecated Use {@link #builder()} to configure specific allowed origins, methods, and headers.
*/
@Deprecated
public static CorsMiddleware allowAll() {
LOGGER.warn("CorsMiddleware.allowAll() is for development only — permits all origins, methods,"
+ " and headers. Do not use in production.");

return builder()
.allowAnyOrigin()
.allowAnyMethod()
Expand Down
1 change: 1 addition & 0 deletions serve-cors/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
*/
module build.serve.cors {
requires transitive build.serve.foundation;
requires build.base.logging;

exports build.serve.cors;
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ void disallowedOriginGetsNoHeaders() throws Exception {
assertThat(response.headers).doesNotContainKey("Access-Control-Allow-Origin");
}

@Test
@SuppressWarnings("deprecation")
void allowAllIsDeprecated() throws Exception {
var method = CorsMiddleware.class.getMethod("allowAll");

assertThat(method.isAnnotationPresent(Deprecated.class)).isTrue();
}

@Test
void credentialsWithWildcardOriginThrows() {
assertThatThrownBy(() -> CorsMiddleware.builder()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*-
* #%L
* Serve Foundation
* %%
* Copyright (C) 2026 Reed von Redwitz
* %%
* 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
*
* http://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.
* #L%
*/
package build.serve.foundation.option;

import build.base.configuration.Option;

import java.time.Duration;

/**
* An {@link Option} to define the maximum time allowed per request handler.
* <p>
* When set, the transport will interrupt the handler thread if it exceeds the timeout,
* protecting against Slowloris and other slow-request attacks.
*
* @param duration the maximum time to allow a request handler to run; {@link Duration#ZERO} disables enforcement
* @author reed.vonredwitz
* @since Apr-2026
*/
public record RequestTimeout(Duration duration)
implements Option {

/**
* No request timeout — handlers run until completion.
*/
public static final RequestTimeout NONE = new RequestTimeout(Duration.ZERO);

/**
* Creates a {@link RequestTimeout} for the specified duration.
*
* @param duration the request timeout duration
* @return a new {@link RequestTimeout}
*/
public static RequestTimeout of(final Duration duration) {
return new RequestTimeout(duration);
}

/**
* Creates a {@link RequestTimeout} for the specified number of seconds.
*
* @param seconds the request timeout in seconds
* @return a new {@link RequestTimeout}
*/
public static RequestTimeout ofSeconds(final long seconds) {
return new RequestTimeout(Duration.ofSeconds(seconds));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import build.serve.foundation.Handler;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.util.List;
import java.util.Objects;

/**
Expand All @@ -47,16 +48,46 @@ private GraphQlHandler() {
* @return a new {@link Handler}
*/
public static Handler graphql(final GraphQlSchema schema) {
return graphql(schema, GraphQlOptions.defaults());
}

/**
* Creates a {@link Handler} that processes GraphQL requests with the given security options.
* <p>
* Options control introspection, max query depth, and max query complexity.
*
* @param schema the {@link GraphQlSchema} to execute against
* @param options the {@link GraphQlOptions} to apply
* @return a new {@link Handler}
*/
public static Handler graphql(final GraphQlSchema schema, final GraphQlOptions options) {
Objects.requireNonNull(schema, "schema must not be null");
Objects.requireNonNull(options, "options must not be null");

final var effectiveSchema = schema.withOptions(options);

return exchange -> {
final var request = MAPPER.readValue(
exchange.request().bodyAsStream(), GraphQlRequest.class);
final var result = schema.execute(request);

final var json = MAPPER.writeValueAsBytes(result);
if (options.disableIntrospection() && isIntrospectionQuery(request.query())) {
final var error = MAPPER.writeValueAsBytes(new GraphQlResult(
null, List.of(new GraphQlError("Introspection is not allowed", null))));
exchange.response()
.status(400)
.header("Content-Type", "application/json")
.send(error);

return;
}

final var result = effectiveSchema.execute(request);
exchange.response().header("Content-Type", "application/json");
exchange.response().send(json);
exchange.response().send(MAPPER.writeValueAsBytes(result));
};
}

private static boolean isIntrospectionQuery(final String query) {
return query != null && (query.contains("__schema") || query.contains("__type"));
}
}
154 changes: 154 additions & 0 deletions serve-graphql/src/main/java/build/serve/graphql/GraphQlOptions.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*-
* #%L
* Serve GraphQL
* %%
* Copyright (C) 2026 Reed von Redwitz
* %%
* 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
*
* http://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.
* #L%
*/
package build.serve.graphql;

/**
* Security and execution controls for a GraphQL endpoint.
* <p>
* Use {@link #defaults()} for no restrictions, or {@link #builder()} for fine-grained control.
*
* @author reed.vonredwitz
* @since Apr-2026
*/
public final class GraphQlOptions {

private final boolean disableIntrospection;
private final int maxDepth;
private final int maxComplexity;

private GraphQlOptions(final Builder builder) {
this.disableIntrospection = builder.disableIntrospection;
this.maxDepth = builder.maxDepth;
this.maxComplexity = builder.maxComplexity;
}

/**
* Returns {@link GraphQlOptions} with no restrictions — introspection enabled, no depth or complexity limits.
*
* @return default {@link GraphQlOptions}
*/
public static GraphQlOptions defaults() {
return builder().build();
}

/**
* Returns a new {@link Builder}.
*
* @return a new {@link Builder}
*/
public static Builder builder() {
return new Builder();
}

/**
* Returns whether introspection queries ({@code __schema}, {@code __type}) are blocked.
*
* @return {@code true} if introspection is disabled
*/
public boolean disableIntrospection() {
return disableIntrospection;
}

/**
* Returns the maximum allowed query depth, or {@code 0} for no limit.
*
* @return the max depth
*/
public int maxDepth() {
return maxDepth;
}

/**
* Returns the maximum allowed query complexity (node count), or {@code 0} for no limit.
*
* @return the max complexity
*/
public int maxComplexity() {
return maxComplexity;
}

/**
* A builder for {@link GraphQlOptions}.
*
* @author reed.vonredwitz
* @since Apr-2026
*/
public static final class Builder {

private boolean disableIntrospection;
private int maxDepth;
private int maxComplexity;

private Builder() {
}

/**
* Blocks introspection queries ({@code __schema} and {@code __type}).
*
* @return this {@link Builder}
*/
public Builder disableIntrospection() {
this.disableIntrospection = true;

return this;
}

/**
* Rejects queries that exceed the specified depth.
*
* @param maxDepth maximum query depth; must be positive
* @return this {@link Builder}
*/
public Builder maxDepth(final int maxDepth) {
if (maxDepth <= 0) {
throw new IllegalArgumentException("maxDepth must be positive");
}

this.maxDepth = maxDepth;

return this;
}

/**
* Rejects queries that exceed the specified complexity (node count).
*
* @param maxComplexity maximum query complexity; must be positive
* @return this {@link Builder}
*/
public Builder maxComplexity(final int maxComplexity) {
if (maxComplexity <= 0) {
throw new IllegalArgumentException("maxComplexity must be positive");
}

this.maxComplexity = maxComplexity;

return this;
}

/**
* Builds the {@link GraphQlOptions}.
*
* @return a new {@link GraphQlOptions}
*/
public GraphQlOptions build() {
return new GraphQlOptions(this);
}
}
}
31 changes: 31 additions & 0 deletions serve-graphql/src/main/java/build/serve/graphql/GraphQlSchema.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,16 @@
import graphql.ExecutionInput;
import graphql.ExecutionResult;
import graphql.GraphQL;
import graphql.analysis.MaxQueryComplexityInstrumentation;
import graphql.analysis.MaxQueryDepthInstrumentation;
import graphql.execution.instrumentation.ChainedInstrumentation;
import graphql.execution.instrumentation.Instrumentation;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.SchemaGenerator;
import graphql.schema.idl.SchemaParser;
import graphql.schema.idl.TypeDefinitionRegistry;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -81,6 +86,32 @@ public GraphQlResult execute(final GraphQlRequest request) {
return new GraphQlResult(result.getData(), errors);
}

/**
* Returns a new {@link GraphQlSchema} with instrumentations derived from the given options applied.
* Used by {@link GraphQlHandler} to wire depth and complexity limits without rebuilding the full schema.
*/
GraphQlSchema withOptions(final GraphQlOptions options) {
final var instrumentations = new ArrayList<Instrumentation>();

if (options.maxDepth() > 0) {
instrumentations.add(new MaxQueryDepthInstrumentation(options.maxDepth()));
}

if (options.maxComplexity() > 0) {
instrumentations.add(new MaxQueryComplexityInstrumentation(options.maxComplexity()));
}

if (instrumentations.isEmpty()) {
return this;
}

return new GraphQlSchema(
GraphQL.newGraphQL(graphQL.getGraphQLSchema())
.instrumentation(new ChainedInstrumentation(instrumentations))
.build()
);
}

/**
* Creates a new {@link Builder} from a GraphQL SDL string.
*
Expand Down
Loading
Loading