Skip to content
Open
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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,26 @@ This project implements a minimal viable version of OpenClaw in Java, focusing o
- `src/main/java/ai/openclaw/channel` - Console channel implementation
- `src/main/java/ai/openclaw/config` - Configuration loader
- `src/main/java/ai/openclaw/session` - Session storage

## Running with Docker

You can run the application in a Docker container for an isolated environment.

1. **Build the Docker image**:
```bash
docker build -t openclaw-java .
```

2. **Run the container**:
You must provide your Anthropic API key as an environment variable.
```bash
docker run -it --rm \
-e ANTHROPIC_API_KEY=sk-ant-... \
-p 18789:18789 \
openclaw-java
```

The container runs as a non-root user (`openclaw`) for security.
- Code execution is confined to `/home/openclaw/workspace`.
- File access is restricted to the workspace directory.
- Network access to internal/private IPs is blocked.
53 changes: 51 additions & 2 deletions src/main/java/ai/openclaw/gateway/GatewayServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,57 @@ public GatewayServer(OpenClawConfig config, RpcRouter router) {

@Override
public void onOpen(WebSocket conn, ClientHandshake handshake) {
logger.info("New connection from {}", conn.getRemoteSocketAddress());
// Simple auth check could be added here
String remoteAddress = conn.getRemoteSocketAddress().toString();
logger.info("New connection from {}", remoteAddress);

String expectedToken = config.getGateway().getAuthToken();
if (expectedToken == null || expectedToken.isEmpty()) {
logger.warn("No auth token configured! Accepting connection from {}", remoteAddress);
return;
}

String providedToken = extractToken(handshake);
if (providedToken == null || !constantTimeEquals(expectedToken, providedToken)) {
logger.warn("Unauthorized connection attempt from {}", remoteAddress);
// Close with policy violation code (1008) or normal code (1000) with reason
conn.close(1008, "Unauthorized");
return;
Comment on lines +42 to +46

Choose a reason for hiding this comment

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

🔴 Authentication bypass: unauthenticated clients can send RPC messages before connection close completes

When an unauthorized client connects, onOpen calls conn.close(1008, "Unauthorized") which initiates a graceful WebSocket close handshake but does not immediately terminate the connection. Between the close frame being sent and the connection actually closing, the client can still send WebSocket messages that will be processed by onMessage, which has no authentication check.

Root Cause and Impact

In the java-websocket library, conn.close() is non-blocking — it sends a close frame and waits for the peer to acknowledge. During this window, onMessage can still fire. Since onMessage at GatewayServer.java:91-115 processes any incoming RPC request without verifying the connection is authenticated, an attacker can:

  1. Connect without a valid token
  2. Immediately send an RPC message (e.g., agent.send) before the close handshake completes
  3. Have the message processed and receive a response

The clients map (line 21) is declared but never used to track authenticated connections, so there's no mechanism to reject messages from unauthenticated connections.

Impact: The authentication check can be bypassed entirely, allowing unauthorized access to all RPC methods.

Prompt for agents
In src/main/java/ai/openclaw/gateway/GatewayServer.java, the authentication in onOpen (lines 31-49) closes the connection but does not prevent messages from being processed during the close handshake window. To fix this:

1. Track authenticated connections using the existing `clients` ConcurrentHashMap (line 21). In onOpen, after successful authentication, add the connection to the map (e.g., `clients.put(conn.getRemoteSocketAddress().toString(), conn)`).

2. In onClose (line 86), remove the connection from the clients map.

3. In onMessage (line 91), add an authentication check at the start: if the connection is not in the clients map, log a warning and return without processing the message. For example:
   if (!clients.containsValue(conn)) {
       logger.warn("Message from unauthenticated connection, ignoring");
       return;
   }

Alternatively, use a Set<WebSocket> for O(1) lookup instead of checking containsValue on the ConcurrentHashMap.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

logger.info("Authenticated connection from {}", remoteAddress);
}

private String extractToken(ClientHandshake handshake) {
// 1. Check Authorization header
String authHeader = handshake.getFieldValue("Authorization");
if (authHeader != null && authHeader.toLowerCase().startsWith("bearer ")) {
return authHeader.substring(7).trim();
}

// 2. Check query parameter
String descriptor = handshake.getResourceDescriptor();
if (descriptor.contains("token=")) {
int index = descriptor.indexOf("token=");
String token = descriptor.substring(index + 6);
int end = token.indexOf('&');
if (end != -1) {
token = token.substring(0, end);
}
return token;
}
return null;
}

/** Constant-time string comparison to prevent timing attacks. */
private boolean constantTimeEquals(String a, String b) {
if (a.length() != b.length()) {
return false;
Comment on lines +74 to +76

Choose a reason for hiding this comment

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

🟡 Early return on length mismatch defeats constant-time comparison

The constantTimeEquals method returns false immediately when the two strings have different lengths (line 75), which leaks the length of the expected auth token through timing side-channels.

Root Cause and Impact

The method is documented as "Constant-time string comparison to prevent timing attacks" (line 73), but the early return at line 75 (if (a.length() != b.length()) return false;) makes the comparison time dependent on whether the attacker guessed the correct token length. An attacker can determine the exact length of the expected token by sending tokens of varying lengths and measuring response times.

Once the token length is known, the search space for brute-forcing is significantly reduced. A proper constant-time comparison should pad or otherwise handle different-length strings without an early return, for example by using MessageDigest.isEqual() which handles this correctly.

Impact: Partial information leakage about the auth token, reducing the effectiveness of the timing-attack protection.

Suggested change
private boolean constantTimeEquals(String a, String b) {
if (a.length() != b.length()) {
return false;
private boolean constantTimeEquals(String a, String b) {
return java.security.MessageDigest.isEqual(
a.getBytes(java.nio.charset.StandardCharsets.UTF_8),
b.getBytes(java.nio.charset.StandardCharsets.UTF_8)
);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
int result = 0;
for (int i = 0; i < a.length(); i++) {
result |= a.charAt(i) ^ b.charAt(i);
}
return result == 0;
}

@Override
Expand Down
4 changes: 4 additions & 0 deletions src/test/java/ai/openclaw/e2e/GatewayE2ETest.java
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ public void onError(Exception ex) {
}
};

final String authToken = "test-token"; // Use the known token directly or capture from config
if (authToken != null) {
client.addHeader("Authorization", "Bearer " + authToken);
}
assertTrue(client.connectBlocking(5, TimeUnit.SECONDS));

// Send request
Expand Down