Skip to content

Commit 497ae56

Browse files
authored
fix: misc security and correctness fixes (#65)
1 parent 64c08ae commit 497ae56

15 files changed

Lines changed: 129 additions & 41 deletions

File tree

serve-auth/src/main/java/build/serve/auth/AuthStrategy.java

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121

2222
import build.serve.foundation.Request;
2323

24+
import java.util.Arrays;
2425
import java.util.Optional;
26+
import java.util.stream.Collectors;
2527

2628
/**
2729
* A strategy for authenticating an inbound {@link Request}.
@@ -69,14 +71,25 @@ default String challenge() {
6971
* @return a composite {@link AuthStrategy}
7072
*/
7173
static AuthStrategy anyOf(final AuthStrategy... strategies) {
72-
return request -> {
73-
for (final var strategy : strategies) {
74-
final var result = strategy.authenticate(request);
75-
if (result.isPresent()) {
76-
return result;
74+
final var compositeChallenge = Arrays.stream(strategies)
75+
.map(AuthStrategy::challenge)
76+
.collect(Collectors.joining(", "));
77+
return new AuthStrategy() {
78+
@Override
79+
public Optional<Principal> authenticate(final Request request) {
80+
for (final var strategy : strategies) {
81+
final var result = strategy.authenticate(request);
82+
if (result.isPresent()) {
83+
return result;
84+
}
7785
}
86+
return Optional.empty();
87+
}
88+
89+
@Override
90+
public String challenge() {
91+
return compositeChallenge;
7892
}
79-
return Optional.empty();
8093
};
8194
}
8295
}

serve-auth/src/main/java/build/serve/auth/BasicAuthStrategy.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ public final class BasicAuthStrategy implements AuthStrategy {
4646

4747
private BasicAuthStrategy(final String realm,
4848
final BiFunction<String, String, Optional<Principal>> validator) {
49+
if (realm.contains("\"") || realm.contains("\r") || realm.contains("\n")) {
50+
throw new IllegalArgumentException("realm must not contain quotes or CRLF");
51+
}
4952
this.realm = realm;
5053
this.validator = validator;
5154
}

serve-auth/src/test/java/build/serve/auth/AuthMiddlewareTests.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import java.util.concurrent.atomic.AtomicReference;
4040

4141
import static org.assertj.core.api.Assertions.assertThat;
42+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
4243

4344
class AuthMiddlewareTests {
4445

@@ -187,6 +188,18 @@ void shouldIncludeRealmInChallenge() {
187188
.isEqualTo("Basic realm=\"myapp\"");
188189
}
189190

191+
@Test
192+
void shouldRejectRealmWithQuote() {
193+
assertThatThrownBy(() -> BasicAuthStrategy.of("my\"app", (u, p) -> Optional.empty()))
194+
.isInstanceOf(IllegalArgumentException.class);
195+
}
196+
197+
@Test
198+
void shouldRejectRealmWithCrlf() {
199+
assertThatThrownBy(() -> BasicAuthStrategy.of("app\r\nX-Injected: val", (u, p) -> Optional.empty()))
200+
.isInstanceOf(IllegalArgumentException.class);
201+
}
202+
190203
// --- ApiKeyStrategy ---
191204

192205
@Test
@@ -233,6 +246,16 @@ void shouldReturnEmptyWhenAllStrategiesFail() {
233246
assertThat(strategy.authenticate(request("X-None", ""))).isEmpty();
234247
}
235248

249+
@Test
250+
void shouldJoinDelegateChallengesInAnyOf() {
251+
var basic = BasicAuthStrategy.of("myapp", (u, p) -> Optional.empty());
252+
var bearer = BearerTokenStrategy.of(t -> Optional.empty());
253+
var strategy = AuthStrategy.anyOf(basic, bearer);
254+
255+
assertThat(strategy.challenge())
256+
.isEqualTo("Basic realm=\"myapp\", Bearer");
257+
}
258+
236259
// --- helpers ---
237260

238261
private static Exchange exchange(final String headerName, final StubResponse response) {

serve-devtools/src/main/java/build/serve/devtools/ChromeDevToolsHandler.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
*/
2020
package build.serve.devtools;
2121

22+
import build.base.logging.Logger;
2223
import build.serve.foundation.Handler;
2324
import build.serve.foundation.util.JsonStrings;
2425

@@ -57,6 +58,7 @@ public final class ChromeDevToolsHandler {
5758

5859
private static final String CONTENT_TYPE = "Content-Type";
5960
private static final String APPLICATION_JSON = "application/json";
61+
private static final Logger LOGGER = Logger.get(ChromeDevToolsHandler.class);
6062

6163
private ChromeDevToolsHandler() {
6264
}
@@ -85,6 +87,7 @@ public static Handler forWorkspace(final Path root,
8587
final UUID uuid) {
8688
Objects.requireNonNull(root, "root");
8789
Objects.requireNonNull(uuid, "uuid");
90+
LOGGER.warn("ChromeDevToolsHandler exposes the absolute filesystem path — do not register in production");
8891
final var json = buildJson(root.toAbsolutePath().normalize().toString(), uuid);
8992
return exchange -> exchange.response()
9093
.status(200)

serve-foundation/src/main/java/build/serve/foundation/Response.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ public interface Response {
5555
*/
5656
Response header(String name, String value);
5757

58+
/**
59+
* Removes a response header so it is not sent to the client.
60+
*
61+
* @param name the header name to remove
62+
*/
63+
default void removeHeader(final String name) {
64+
}
65+
5866
/**
5967
* Sends a plain text body and closes the response.
6068
*

serve-graphql/src/main/java/build/serve/graphql/GraphiQlHandler.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ public final class GraphiQlHandler {
7171
private GraphiQlHandler() {
7272
}
7373

74+
private static String escapeJs(final String s) {
75+
return s.replace("\\", "\\\\")
76+
.replace("'", "\\'")
77+
.replace("\r", "\\r")
78+
.replace("\n", "\\n");
79+
}
80+
7481
/**
7582
* Creates a {@link Handler} that serves the GraphiQL IDE, configured to send
7683
* queries to the given GraphQL endpoint.
@@ -81,7 +88,7 @@ private GraphiQlHandler() {
8188
public static Handler graphiql(final String endpoint) {
8289
Objects.requireNonNull(endpoint, "endpoint must not be null");
8390

84-
final var html = TEMPLATE.replace("{{ENDPOINT}}", endpoint)
91+
final var html = TEMPLATE.replace("{{ENDPOINT}}", escapeJs(endpoint))
8592
.getBytes(StandardCharsets.UTF_8);
8693

8794
return exchange -> {

serve-graphql/src/test/java/build/serve/graphql/GraphiQlHandlerTests.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,19 @@ void bodyContainsGraphiQlMarkers() throws Exception {
7474
.contains("graphiql")
7575
.contains("GraphiQL");
7676
}
77+
78+
@Test
79+
void shouldEscapeEndpointInJsStringLiteral() throws Exception {
80+
final var dangerousServer = TestServer.of(GraphiQlHandler.graphiql("/gql?a=1&b='x'\\y"));
81+
82+
try {
83+
final var body = dangerousServer.get("/").send().assertStatus(200).body();
84+
assertThat(body)
85+
.doesNotContain("'x'")
86+
.contains("\\'x\\'")
87+
.contains("\\\\y");
88+
} finally {
89+
dangerousServer.close();
90+
}
91+
}
7792
}

serve-lsp/src/main/java/build/serve/lsp/LspTransport.java

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import java.io.IOException;
3333
import java.io.InputStream;
3434
import java.io.OutputStream;
35+
import java.net.SocketException;
3536
import java.nio.charset.StandardCharsets;
3637
import java.util.List;
3738
import java.util.concurrent.atomic.AtomicBoolean;
@@ -70,18 +71,22 @@ public static void stdio(final LspServer server) throws Exception {
7071
public static void tcp(final LspServer server, final int port) throws IOException {
7172
try (var serverSocket = new java.net.ServerSocket(port)) {
7273
while (true) {
73-
final var socket = serverSocket.accept();
74-
Thread.ofVirtual().start(() -> {
75-
try {
76-
run(server, socket.getInputStream(), socket.getOutputStream());
77-
} catch (final Exception ignored) {
78-
} finally {
74+
try {
75+
final var socket = serverSocket.accept();
76+
Thread.ofVirtual().start(() -> {
7977
try {
80-
socket.close();
81-
} catch (final IOException ignored) {
78+
run(server, socket.getInputStream(), socket.getOutputStream());
79+
} catch (final Exception ignored) {
80+
} finally {
81+
try {
82+
socket.close();
83+
} catch (final IOException ignored) {
84+
}
8285
}
83-
}
84-
});
86+
});
87+
} catch (final SocketException e) {
88+
break;
89+
}
8590
}
8691
}
8792
}

serve-security/src/main/java/build/serve/security/SecurityHeadersMiddleware.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ public Handler apply(final Handler next) {
101101
setIfNonNull(response, "Cross-Origin-Resource-Policy", crossOriginResourcePolicy);
102102

103103
for (final var header : removeHeaders) {
104-
response.header(header, "");
104+
response.removeHeader(header);
105105
}
106106
};
107107
}

serve-security/src/test/java/build/serve/security/SecurityHeadersMiddlewareTests.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ void removeHeaderStripsHeaders() throws Exception {
113113
middleware.apply(ex -> {
114114
}).handle(exchange);
115115

116-
assertThat(response.headers).containsEntry("X-Powered-By", "");
117-
assertThat(response.headers).containsEntry("Server", "");
116+
assertThat(response.headers).doesNotContainKey("X-Powered-By");
117+
assertThat(response.headers).doesNotContainKey("Server");
118118
}
119119

120120
@Test
@@ -217,6 +217,11 @@ public Response header(final String name, final String value) {
217217
return this;
218218
}
219219

220+
@Override
221+
public void removeHeader(final String name) {
222+
headers.remove(name);
223+
}
224+
220225
@Override
221226
public void send(final String body) {
222227
}

0 commit comments

Comments
 (0)