Skip to content

Commit 52ecc2d

Browse files
committed
feat: Add lws_captcha_ratelimit plugin and core integration
Co-developed-by: Gemini 3.0 Pro
1 parent 3c64a61 commit 52ecc2d

34 files changed

+2036
-480
lines changed

READMEs/README-captcha.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# lws_captcha_ratelimit plugin
2+
3+
This plugin provides a simple interceptor mechanism based on a time delay (rate limit). It is designed to be used with the LWS `interceptor_path` mount option.
4+
5+
## Functionality
6+
7+
When a user accesses a protected mount, LWS checks for a valid JWT cookie. If the cookie is missing or invalid, the request is diverted to the `interceptor_path`. This plugin, when mounted at `interceptor_path`, serves a simple HTML page with a button.
8+
9+
Both before, and after when the user clicks the button, the plugin enforces a configurable wait. After the wait, it issues a signed JWT cookie valid for a configured duration (default 10 minutes) and redirects the user back to the original URL.
10+
11+
## Configuration
12+
13+
### 1. Enable the Plugin
14+
15+
Ensure `protocol_lws_captcha_ratelimit` is enabled in your build.
16+
17+
### 2. Configure Vhost PVOs
18+
19+
The plugin is configured via per-vhost options (PVOs) under the protocol name `lws_captcha_ratelimit`.
20+
21+
| Option | Description | Default |
22+
|---|---|---|
23+
| `jwt-issuer` | The issuer claim (`iss`) for the JWT. | "lws" |
24+
| `jwt-audience` | The audience claim (`aud`) for the JWT. | "lws" |
25+
| `jwt-alg` | The signing algorithm. | "HS256" |
26+
| `jwt-expiry` | Validity duration of the JWT in seconds. | 600 (10 mins) |
27+
| `cookie-name` | The name of the cookie to set/check. | "lws_captcha_ratelimit" |
28+
| `jwt-jwk` | **Required.** Path to a file containing the JWK (JSON Web Key) or the JWK JSON itself. | - |
29+
| `asset-dir` | Path to interceptor assets (CSS, JS, images). Use `file://` prefix for local paths. | - |
30+
| `pre-delay-ms` | Delay before "Continue" button appears. | 5000 |
31+
| `post-delay-ms` | Delay after "Continue" button is pressed. | 3000 |
32+
| `status` | Status message to display. | "ok" |
33+
| `stats-logging` | Whether to emit once-a-minute status logging | 0 |
34+
35+
**JWK Generation:**
36+
37+
The JWK can be produced by `lws-crypto-jwk -t OCT`.
38+
39+
**Example JWK (`captcha-key.jwk`):**
40+
```json
41+
{"kty":"oct","k":"AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow"}
42+
```
43+
44+
### 3. Configure Mounts
45+
46+
In your JSON configuration (e.g., `vhosts`), configure the protected mount with `interceptor-path` pointing to the mount handled by this plugin.
47+
48+
**Example `localhost` vhost config:**
49+
50+
```json
51+
{
52+
"vhosts": [{
53+
"name": "localhost",
54+
"port": "7681",
55+
"ws-protocols": [{
56+
"lws_captcha_ratelimit": {
57+
"status": "ok",
58+
"jwt-jwk": "{\"k\":\"626UazEjGLhz1Kzrdi427lg0Z4cAamGNlz7O1rgFwECsjQBmmcgBYaj-LIK-vJp67gDUnUlq0GL44Br2_kox6VlX8iS24vVaDqrTNuCW-sEM06yJn2BJXDCn-ng3WsliA02U7CLu7UFOPr7kL6kXRGLSKkg0m5LaeyNO7q0vHEZyCLGEdyYCYjzYXhw8gny4qzlYCMsFvt6VoWnOEGeR4AS1J0s8KjCEb30RoQpRIipPdvjWSgVJKHRbOwXg-eE7R1YSUkgOD6ogyEzoDpNxTS2o0CNy0hNykZDYPzca01Smo3BAs3faSFqurtYRxEBhMk1yqkk3GI_jJma19KIfVrQN6vS5IQRyOyonRpH9uwCGm_I-NTquic4SRaBsjPxZ8bmTvtkQ1SgvySWNiMZ2St_F99K4VCXM1ZfUUK2B7aZ3cf4o4ZFh0J46Do8HNfuxvG_OT9B55r3dZ5tvfgbZwURBTWNmnUtDJPfWxswe6eYghU-jYscCdEyxzVWBUc_ujA_DOcFGKLycMOvLXo41Ho4TLHyX65u4ypAciER_QDkx8EGRPQvreByNEp2DLftEiZ02ImTduCpcWehcDBJ_1d3an0x1k4DRWbpk8T1BuanH1o77QUAqGKyW2rTo_IMO0ZE-0JqC_vOKlh46i9Wp9xi73zysDKkqex6MkyqAflE\",\"kty\":\"oct\"}",
59+
"jwt-issuer": "lws-test",
60+
"jwt-audience": "lws-test",
61+
"jwt-alg": "HS256",
62+
"jwt-expiry": 600,
63+
"cookie-name": "lws_captcha_jws",
64+
"asset-dir": "file://_lws_ddir_/libwebsockets-test-server/captcha-ratelimit/captcha-assets",
65+
"pre-delay-ms": 5000,
66+
"post-delay-ms": 3000
67+
}
68+
}],
69+
"mounts": [{
70+
"mountpoint": "/",
71+
"origin": "/var/www/html",
72+
"default": "index.html",
73+
"interceptor-path": "/captcha"
74+
}, {
75+
"mountpoint": "/captcha",
76+
"origin": "callback://lws_captcha_ratelimit",
77+
"protocol": "lws_captcha_ratelimit"
78+
}]
79+
}]
80+
}
81+
```
82+
83+
Note that you should not use the provided example jwt-jwk in production.
84+
You can regenerate one with `lws-crypto-jwk -t OCT` and copy it into place in the config.
85+
86+
In this example:
87+
1. Requests to `/` (and subpaths) are checked for a valid JWT.
88+
2. If invalid, they are diverted to `/captcha`.
89+
3. `/captcha` is handled by the `lws_captcha_ratelimit` protocol.
90+
4. The plugin serves the interceptor UI.
91+
5. Upon success, a cookie is set, and the user is redirected back to `/`.

include/libwebsockets.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,9 @@ lws_fx_string(const lws_fx_t *a, char *buf, size_t size);
753753
#endif
754754

755755
#include <libwebsockets/lws-protocols-plugins.h>
756+
#if defined(LWS_WITH_JOSE)
757+
#include <libwebsockets/lws-interceptor.h>
758+
#endif
756759

757760
#include <libwebsockets/lws-context-vhost.h>
758761

include/libwebsockets/lws-callbacks.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,14 @@ enum lws_callback_reasons {
884884
* Return nonzero to close the wsi.
885885
*/
886886

887+
LWS_CALLBACK_HTTP_INTERCEPTOR_CHECK = 213,
888+
/**< A mount has a interceptor_path enabled, this callback asks the
889+
* protocol bound to that mount if it is OK for this request to
890+
* proceed. If returning 0, the request proceeds to the original
891+
* mount. If nonzero, the request is diverted to the interceptor_path
892+
* mount.
893+
*/
894+
887895
/****** add new things just above ---^ ******/
888896

889897
LWS_CALLBACK_USER = 1000,

include/libwebsockets/lws-context-vhost.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1433,6 +1433,12 @@ struct lws_http_mount {
14331433
/**< 0 or seconds http stream should stay alive while
14341434
* idle. 0 means use the vhost value for keepalive_timeout.
14351435
*/
1436+
#if defined(LWS_WITH_JOSE)
1437+
const char *interceptor_path;
1438+
/**< NULL, or an alternative mount path to divert the connection to
1439+
* if the protocol on that mount says we are not authorized.
1440+
*/
1441+
#endif
14361442

14371443
/* Add new things just above here ---^
14381444
* This is part of the ABI, don't needlessly break compatibility

include/libwebsockets/lws-http.h

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,20 @@
5353
LWS_VISIBLE LWS_EXTERN const char *
5454
lws_get_mimetype(const char *file, const struct lws_http_mount *m);
5555

56+
/**
57+
* lws_find_mount() - Find the mount context for a URI
58+
*
59+
* \param wsi: wsi context
60+
* \param uri_ptr: pointer to the URI
61+
* \param uri_len: length of the URI
62+
*
63+
* Returns NULL or a pointer to the matching mount.
64+
*/
65+
#if defined(LWS_ROLE_H1) || defined(LWS_ROLE_H2)
66+
LWS_VISIBLE LWS_EXTERN const struct lws_http_mount *
67+
lws_find_mount(struct lws *wsi, const char *uri_ptr, int uri_len);
68+
#endif
69+
5670
/**
5771
* lws_serve_http_file() - Send a file back to the client using http
5872
* \param wsi: Websocket instance (available from user callback)
@@ -559,6 +573,39 @@ lws_get_urlarg_by_name_safe(struct lws *wsi, const char *name, char *buf, int le
559573
LWS_VISIBLE LWS_EXTERN const char *
560574
lws_get_urlarg_by_name(struct lws *wsi, const char *name, char *buf, int len)
561575
/* LWS_WARN_DEPRECATED */;
576+
577+
/**
578+
* lws_http_remove_urlarg() - remove a named urlarg from the header table
579+
*
580+
* \param wsi: the connection to check
581+
* \param name: the arg name to remove, eg "token" or "token="
582+
*
583+
* This removes the named argument from the WSI_TOKEN_HTTP_URI_ARGS fragment
584+
* chain in the header table (ah). It does not reclaim space in the ah data
585+
* area, but the argument will no longer be visible to subsequent protocols
586+
* handling the same wsi.
587+
*
588+
* Returns 0 if removed, else nonzero.
589+
*/
590+
LWS_VISIBLE LWS_EXTERN int
591+
lws_http_remove_urlarg(struct lws *wsi, const char *name);
592+
593+
/**
594+
* lws_http_zap_header() - remove a named header from the header table
595+
*
596+
* \param wsi: the connection to check
597+
* \param name: the header name to remove, eg "X-Auth-User"
598+
*
599+
* This removes the named header from the header table (ah). It handles both
600+
* recognized tokens and unknown custom headers. Note that it does not reclaim
601+
* space in the ah data area, but the header will no longer be visible to
602+
* subsequent protocols or forwarded during proxying.
603+
*
604+
* Returns 0 if removed or not found, else nonzero.
605+
*/
606+
LWS_VISIBLE LWS_EXTERN int
607+
lws_http_zap_header(struct lws *wsi, const char *name);
608+
562609
///@}
563610

564611
/*! \defgroup HTTP-headers-create HTTP headers: create
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#include <stddef.h>
2+
#if defined(LWS_WITH_JOSE)
3+
/*
4+
* libwebsockets - small server side websockets and web server implementation
5+
*
6+
* Copyright (C) 2010 - 2025 Andy Green <andy@warmcat.com>
7+
*
8+
* Permission is hereby granted, free of charge, to any person obtaining a copy
9+
* of this software and associated documentation files (the "Software"), to
10+
* deal in the Software without restriction, including without limitation the
11+
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
12+
* sell copies of the Software, and to permit persons to whom the Software is
13+
* furnished to do so, subject to the following conditions:
14+
*
15+
* The above copyright notice and this permission notice shall be included in
16+
* all copies or substantial portions of the Software.
17+
*
18+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23+
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
24+
* IN THE SOFTWARE.
25+
*/
26+
27+
/** \defgroup captcha Captcha
28+
* ##Captcha API
29+
*
30+
* Lws provides a generic captcha "engine" that handles JWT sessions,
31+
* redirection flows, and asset serving. Captcha implementations (puzzles,
32+
* ratelimits, etc) provide a `struct lws_captcha_ops` to customize the
33+
* behavior.
34+
*/
35+
/**@{*/
36+
37+
typedef enum {
38+
LWS_INTERCEPTOR_RET_REJECT = 0, /* Failed the challenge */
39+
LWS_INTERCEPTOR_RET_PASS = 1, /* Passed immediately */
40+
LWS_INTERCEPTOR_RET_DELAYED = 2, /* Use a timer before passing (for ratelimiting) */
41+
} lws_interceptor_result_t;
42+
43+
struct lws_interceptor_ops {
44+
const char *name; /* e.g., "ratelimit" or "puzzle" */
45+
46+
/**
47+
* [Optional] Add implementation-specific dynamic JS variables to
48+
* the automatically served "captcha-config.js".
49+
*/
50+
int (*get_config_js)(struct lws *wsi, char *buf, size_t len);
51+
52+
/**
53+
* [Optional] Customize the "visit" JWT claims.
54+
* e.g., Store a random math problem or puzzle seed here.
55+
*/
56+
int (*init_visit_cookie)(struct lws *wsi, char *buf, size_t len);
57+
58+
/**
59+
* Verify the POSTed challenge submission.
60+
* Decides if the user passes, fails, or needs to wait.
61+
*/
62+
lws_interceptor_result_t (*verify)(struct lws *wsi, const void *data, size_t len);
63+
64+
/**
65+
* [Optional] Extra logic to run if verify returned LWS_INTERCEPTOR_RET_DELAYED
66+
* and the timer expired.
67+
*/
68+
void (*on_delay_expired)(struct lws *wsi);
69+
};
70+
71+
/**
72+
* lws_interceptor_check() - Check if a valid interceptor session exists
73+
*
74+
* \param wsi: the connection to check
75+
*
76+
* Returns 0 if a valid interceptor session exists (JWT cookie present and valid for IP),
77+
* non-zero if a interceptor diversion is required.
78+
*/
79+
LWS_VISIBLE LWS_EXTERN int
80+
lws_interceptor_check(struct lws *wsi, const struct lws_protocols *prot);
81+
82+
/**
83+
* lws_interceptor_handle_http() - Generic HTTP handler for interceptor plugins
84+
*
85+
* \param wsi: the connection
86+
* \param user: PSS
87+
* \param ops: the interceptor implementations ops
88+
*
89+
* This handles serving assets, config JS, and processing POST submissions
90+
* using the provided wheat.
91+
*/
92+
LWS_VISIBLE LWS_EXTERN int
93+
lws_interceptor_handle_http(struct lws *wsi, void *user, const struct lws_interceptor_ops *ops);
94+
95+
/**
96+
* lws_callback_interceptor() - Generic protocol callback for interceptor plugins
97+
*/
98+
LWS_VISIBLE LWS_EXTERN int
99+
lws_callback_interceptor(struct lws *wsi, enum lws_callback_reasons reason,
100+
void *user, void *in, size_t len,
101+
const struct lws_interceptor_ops *ops);
102+
103+
/**@}*/
104+
105+
#endif

0 commit comments

Comments
 (0)