Skip to content

Commit

Permalink
Apply ACL rules to WebSocket commands
Browse files Browse the repository at this point in the history
ACLs were not considered when processing commands coming over WebSocket
connections. WS commands that are disabled with ACLs are now rejected
with a custom message for JSON and raw WS clients, the two supported
formats for this protocol. For JSON an equivalent HTTP status code is
included in the response, although this is only an indication of how
Webdis would have responded if it came from a regular HTTP request.

Tests are added to validate that DEBUG commands are rejected by Webdis
without even making it to Redis, for both JSON and raw WS clients.

The error responses are documented in the README in the ACL section.

Fixes #240.
  • Loading branch information
jessie-murray committed Oct 3, 2023
1 parent bb6a3c0 commit 47cfcbe
Show file tree
Hide file tree
Showing 8 changed files with 96 additions and 1 deletion.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,21 @@ Examples:
```
ACLs are interpreted in order, later authorizations superseding earlier ones if a client matches several. The special value "*" matches all commands.

## ACLs and Websocket clients

These rules apply to WebSocket connections as well, although without support for HTTP Basic Auth filtering. IP filtering is supported.

For JSON-based WebSocket clients, a rejected command will return this object (sent as a string in a binary frame):
```json
{"message": "Forbidden", "error": true, "http_status": 403}
```
The `http_status` code is an indicator of how Webdis would have responded if the client had used HTTP instead of a WebSocket connection, since WebSocket messages do not inherently have a status code.

For raw Redis protocol WebSocket clients, a rejected command will produce this error (sent as a string in a binary frame):
```
-ERR Forbidden\r\n
```

# Environment variables

Environment variables can be used in `webdis.json` to read values from the environment instead of using constant values.
Expand Down
2 changes: 2 additions & 0 deletions src/cmd.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ struct worker;
struct cmd;

typedef void (*formatting_fun)(redisAsyncContext *, void *, void *);
typedef char* (*ws_error_fun)(int http_status, const char *msg, size_t msg_sz, size_t *out_sz);

typedef enum {CMD_SENT,
CMD_PARAM_ERROR,
CMD_ACL_FAIL,
Expand Down
20 changes: 20 additions & 0 deletions src/formats/json.c
Original file line number Diff line number Diff line change
Expand Up @@ -554,3 +554,23 @@ json_ws_extract(struct http_client *c, const char *p, size_t sz) {
json_decref(j);
return cmd;
}

/* Formats a WebSocket error message */
char* json_ws_error(int http_status, const char *msg, size_t msg_sz, size_t *out_sz) {

(void)msg_sz; /* unused */
json_t *jroot = json_object();
char *jstr;

/* e.g. {"message": "Forbidden", "error": true, "http_status": 403} */
/* Note: this is only an equivalent HTTP status code, we're sending a WS message not an HTTP response */
json_object_set_new(jroot, "error", json_true());
json_object_set_new(jroot, "message", json_string(msg));
json_object_set_new(jroot, "http_status", json_integer(http_status));

jstr = json_string_output(jroot, NULL);
json_decref(jroot);

*out_sz = strlen(jstr);
return jstr;
}
3 changes: 3 additions & 0 deletions src/formats/json.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ json_string_output(json_t *j, const char *jsonp);
struct cmd *
json_ws_extract(struct http_client *c, const char *p, size_t sz);

char*
json_ws_error(int http_status, const char *msg, size_t msg_sz, size_t *out_sz);

#endif
19 changes: 19 additions & 0 deletions src/formats/raw.c
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,22 @@ raw_wrap(const redisReply *r, size_t *sz) {
}
}


/* Formats a WebSocket error message */
char* raw_ws_error(int http_status, const char *msg, size_t msg_sz, size_t *out_sz) {

(void)http_status; /* unused */
char *ret, *p;

/* e.g. "-ERR unknown command 'foo'\r\n" */
*out_sz = 5 + msg_sz + 2;
p = ret = malloc(*out_sz);

memcpy(p, "-ERR ", 5);
p += 5;
memcpy(p, msg, msg_sz);
p += msg_sz;
memcpy(p, "\r\n", 2);

return ret;
}
3 changes: 3 additions & 0 deletions src/formats/raw.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ raw_reply(redisAsyncContext *c, void *r, void *privdata);
struct cmd *
raw_ws_extract(struct http_client *c, const char *p, size_t sz);

char*
raw_ws_error(int http_status, const char *msg, size_t msg_sz, size_t *out_sz);

#endif
25 changes: 24 additions & 1 deletion src/websocket.c
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "sha1/sha1.h"
#include <b64/cencode.h>
#include "acl.h"
#include "websocket.h"
#include "client.h"
#include "cmd.h"
Expand Down Expand Up @@ -255,21 +256,33 @@ ws_log_cmd(struct ws_client *ws, struct cmd *cmd) {
slog(ws->http_client->s, WEBDIS_DEBUG, log_msg, p - log_msg);
}

static void
ws_log_unauthorized(struct ws_client *ws) {
if(!slog_enabled(ws->http_client->s, WEBDIS_DEBUG)) {
return;
}
const char msg[] = "WS: 403";
slog(ws->http_client->s, WEBDIS_DEBUG, msg, sizeof(msg)-1);
}


static int
ws_execute(struct ws_client *ws, struct ws_msg *msg) {

struct http_client *c = ws->http_client;
struct cmd*(*fun_extract)(struct http_client *, const char *, size_t) = NULL;
formatting_fun fun_reply = NULL;
ws_error_fun fun_error = NULL;

if((c->path_sz == 1 && strncmp(c->path, "/", 1) == 0) ||
strncmp(c->path, "/.json", 6) == 0) {
fun_extract = json_ws_extract;
fun_reply = json_reply;
fun_error = json_ws_error;
} else if(strncmp(c->path, "/.raw", 5) == 0) {
fun_extract = raw_ws_extract;
fun_reply = raw_reply;
fun_error = raw_ws_error;
}

if(fun_extract) {
Expand Down Expand Up @@ -311,7 +324,17 @@ ws_execute(struct ws_client *ws, struct ws_msg *msg) {
int is_subscribe = cmd_is_subscribe_args(cmd);
int is_unsubscribe = cmd_is_unsubscribe_args(cmd);

if(ws->ran_subscribe && !is_subscribe && !is_unsubscribe) { /* disallow non-subscribe commands after a subscribe */
/* check that the client is able to run this command */
if(!acl_allow_command(cmd, c->s->cfg, c)) {
const char msg[] = "Forbidden";
size_t error_sz;
char *error = fun_error(403, msg, sizeof(msg)-1, &error_sz);
ws_frame_and_send_response(ws, WS_BINARY_FRAME, error, error_sz);
free(error);
/* similar to HTTP: log command first and then rejection, both with "WS: " prefix */
ws_log_cmd(ws, cmd);
ws_log_unauthorized(ws);
} else if(ws->ran_subscribe && !is_subscribe && !is_unsubscribe) { /* disallow non-subscribe commands after a subscribe */
char error_msg[] = "Command not allowed after subscribe";
ws_frame_and_send_response(ws, WS_BINARY_FRAME, error_msg, sizeof(error_msg)-1);
} else { /* log and execute */
Expand Down
10 changes: 10 additions & 0 deletions tests/ws-tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ def deserialize(self, response):
def test_ping(self):
self.assertEqual(self.exec('PING'), {'PING': [True, 'PONG']})

def test_acl(self):
key, value = self.clean_key(), str(uuid.uuid4())
self.assertEqual(self.exec('SET', key, value), {'SET': [True, 'OK']})
self.assertEqual(self.exec('DEBUG', 'OBJECT', key), {'error': True, 'message': 'Forbidden', 'http_status': 403})

def test_multiple_messages(self):
key = self.clean_key()
n = 100
Expand All @@ -92,6 +97,11 @@ def deserialize(self, response):
def test_ping(self):
self.assertEqual(self.exec('PING'), "+PONG\r\n")

def test_acl(self):
key, value = self.clean_key(), str(uuid.uuid4())
self.assertEqual(self.exec('SET', key, value), "+OK\r\n")
self.assertEqual(self.exec('DEBUG', 'OBJECT', key), "-ERR Forbidden\r\n")

def test_get_set(self):
key = self.clean_key()
value = str(uuid.uuid4())
Expand Down

0 comments on commit 47cfcbe

Please sign in to comment.