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 docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ Here are some recommendations to avoid them and build-time flags you can change.
- `ASYNCWEBSERVER_USE_CHUNK_INFLIGHT`: inflight control for chunked responses.
If you need to serve chunk requests with a really low buffer (which should be avoided), you can set `-D ASYNCWEBSERVER_USE_CHUNK_INFLIGHT=0` to disable the in-flight control.

- `ASYNCWEBSERVER_DIGEST_NONCE_TTL`: validity window, in seconds, of a Digest authentication nonce (default: 60).
Digest replay protection uses stateless signed nonces: beyond this window a nonce is stale
and clients silently re-authenticate via the `stale=true` challenge directive. Set to 0 to disable expiry
(not recommended: widens the replay window). Can also be tuned at runtime with `server.setDigestNonceTimeout()`.

> [!NOTE]
> This relates to ESP32 only, ESP8266 uses different ESPAsyncTCP lib that does not has this build options

Expand Down
70 changes: 70 additions & 0 deletions docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,76 @@ myHandler.addMiddleware(&authMiddleware); // add authentication to a specific ha

See the [Auth example here](https://github.com/ESP32Async/ESPAsyncWebServer/blob/master/examples/arduino/Auth/Auth.ino).

### Digest authentication: replay protection

Digest authentication includes server-side protections against replay attacks, using a **stateless** scheme
(no per-nonce storage, keeping the server's memory footprint constant):

- **Signed nonce**: each challenge nonce embeds its issuing time and is signed with a per-server-instance
secret (`HMAC-MD5`). A nonce not issued by this server is rejected, and an expired nonce triggers a
new challenge with `stale=true` (RFC 7616 §3.3), so compliant clients (browsers, curl) re-authenticate
silently without prompting the user again.
- **Opaque**: each challenge carries an opaque derived from the instance secret, identifying the server
instance that issued the nonce.

The nonce validity window defaults to 60 seconds (`ASYNCWEBSERVER_DIGEST_NONCE_TTL` build flag) and can be
tuned at runtime:

```c++
server.setDigestNonceTimeout(300); // extend nonce lifetime to 5 minutes
```

Changing the instance secret instantly revokes every nonce in circulation (all clients get re-challenged):

```c++
uint8_t secret[16] = {/* 16 random bytes */};
server.setDigestSecret(secret); // kill-switch: invalidates all outstanding nonces
```

> [!NOTE]
> As with any stateless scheme, a captured `Authorization: Digest ...` header remains valid for the
> remainder of the nonce TTL. The library does not keep the raw request-target in memory, so the
> header `uri` field is not checked by default — to bind a header to the resource it was issued for
> (RFC 7616 §3.4.6), pass the expected request-target with `authenticate(..., nonce, opaque, uri)`
> (see below).
> For one-shot semantics, keep the TTL short and serve Digest-protected endpoints over HTTPS.

For custom validation of every digest parameter, `AsyncWebServerRequest::authenticate()` accepts
optional `nonce`, `opaque` and `uri` arguments — these only apply to the Digest auth scheme and are
ignored otherwise. A NULL argument lets the library validate it automatically, while a non-NULL
value is compared verbatim against the caller-supplied value:

```c++
// the library validates nonce and opaque (header uri is left unchecked)
request->authenticate("admin", "admin", "MyApp");

// the caller takes over the uri check and binds the header to a request-target
// (nonce/opaque are still validated by the library)
request->authenticate("admin", "admin", "MyApp", false, NULL, NULL, "/my/custom/uri");
```

### Re-authentication required

`AsyncWebServerRequest::reAuthRequired()` returns true when the last `authenticate()` call failed
for a reason that a re-challenge can resolve, i.e. re-sending the same credentials against a fresh
challenge will eventually succeed.

With Digest auth: the credentials were correct and the nonce was issued by this server, but it
expired. `requestAuthentication()` then automatically issues the next challenge with `stale=true`
(RFC 7616 §3.3), so compliant clients (browsers, curl) re-authenticate silently without prompting
the user again — no special handling is needed from your code:

```c++
if (!request->authenticate("admin", "admin", "MyApp")) {
// reAuthRequired() is true when only the nonce expired: the challenge issued
// below will carry stale=true and the client will retry silently
request->requestAuthentication(AsyncAuthType::AUTH_DIGEST, "MyApp");
return;
}
```

Future authentication mechanisms may reuse the same flag for their own equivalent conditions.

### Migration to Middleware to improve performance and memory usage

- `AsyncEventSource.authorizeConnect(...)` => do not use this method anymore: add a common `AsyncAuthorizationMiddleware` to the handler or server, and make sure to add it AFTER the `AsyncAuthenticationMiddleware` if you use authentication.
Expand Down
27 changes: 26 additions & 1 deletion examples/arduino/Auth/Auth.ino
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,15 @@ void setup() {
.addMiddleware(&digestAuth);

// digest authentication with hash
// protected with a hash: MD5(user:realm:pass) so no plain password is stored in RAM
// this is the privileged endpoint used by test_digest.sh to prove credentials are
// checked against the pre-computed hash instead of the plain password
// curl -v -u admin:admin --digest http://192.168.4.1/auth-digest-hash
server
.on(
"/auth-digest-hash", HTTP_GET,
[](AsyncWebServerRequest *request) {
request->send(200, "text/plain", "Hello, world!");
request->send(200, "text/plain", "DIGEST-HASH-ONLY: sensitive action");
}
)
.addMiddleware(&digestAuthHash);
Expand All @@ -184,6 +187,28 @@ void setup() {
)
.addMiddlewares({&complexAuth, &authz});

// digest authentication with caller-enforced uri binding (RFC 7616 §3.4.6):
// the library does not keep the raw request-target in memory, so binding the header
// "uri" field to the expected request-target is done explicitly by the caller.
// Nonce and opaque are still validated by the library.
static AsyncMiddlewareFunction digestUriAuth([](AsyncWebServerRequest *request, ArMiddlewareNext next) {
if (request->authenticate("admin", "admin", "MyApp", false, NULL, NULL, "/auth-digest-bound")) {
next();
} else {
request->requestAuthentication(AsyncAuthType::AUTH_DIGEST, "MyApp");
}
});
// curl -v --digest -u admin:admin http://192.168.4.1/auth-digest-bound => OK
// a digest header issued for another URI (uri="/auth-digest") => 401
server
.on(
"/auth-digest-bound", HTTP_GET,
[](AsyncWebServerRequest *request) {
request->send(200, "text/plain", "URI-BOUND: sensitive action");
}
)
.addMiddleware(&digestUriAuth);

// Bearer authentication with a shared key
// curl -v -H "Authorization: Bearer shared-secret-key" http://192.168.4.1/auth-bearer-shared-key => OK
server
Expand Down
138 changes: 138 additions & 0 deletions examples/arduino/Auth/test_digest.sh
Comment thread
mathieucarbou marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/bin/bash

# Digest authentication anti-replay test
# Tests the digest endpoints defined in Auth.ino
#
# The library validates the signed nonce and the opaque, but does NOT check the
# header "uri" field by default (the raw request-target is not kept in memory):
# a captured "Authorization: Digest ..." header stays usable within the nonce TTL
# on any endpoint sharing the same credentials and realm. To bind a header to a
# request-target, the caller must pass the expected uri explicitly
# (see /auth-digest-bound, implemented in Auth.ino).
#
# Usage: ./test_digest.sh [SERVER_IP] [SERVER_PORT]

SERVER_IP="${1:-192.168.4.1}"
SERVER_PORT="${2:-80}"
BASE_URL="http://${SERVER_IP}:${SERVER_PORT}"
AUTH_USER="admin"
AUTH_PASS="admin"
REALM="MyApp"

echo "Testing Digest auth anti-replay protections at $BASE_URL"
echo "====================================================="

NB_PASS=0
NB_FAIL=0

ok() { echo "✅ PASS ($1)"; ((NB_PASS++)); }
ko() { echo "❌ FAIL ($1)"; ((NB_FAIL++)); }

# check(status, expected, description)
check() {
if [ "$1" = "$2" ]; then ok "$3: $1"; else ko "$3: expected $2, got $1"; fi
}

# Portable MD5 of stdin, hex-only output (Linux: md5sum; OpenSSL CLI; macOS: BSD md5)
md5s() {
if command -v md5sum >/dev/null 2>&1; then
md5sum | cut -d' ' -f1
elif command -v openssl >/dev/null 2>&1; then
openssl dgst -md5 -r | cut -d' ' -f1
else
md5 -q
fi
}

# get_challenge(path): prints the last received WWW-Authenticate challenge
get_challenge() {
curl -s -i "$BASE_URL$1" | grep -i "^WWW-Authenticate:" | tail -1 | cut -d' ' -f2- | tr -d '\r'
}

extract() {
echo "$1" | grep -o "$2=\"[^\"]*\"" | cut -d'"' -f2
}

# build_auth(nonce, opaque, uri): prints a valid Authorization header for admin/admin
build_auth() {
local nonce="$1" opaque="$2" uri="$3"
local cnonce ha1 ha2 resp
cnonce=$(printf '%s%s' "$nonce" "$opaque" | md5s | cut -c1-16)
ha1=$(printf '%s:%s:%s' "$AUTH_USER" "$REALM" "$AUTH_PASS" | md5s)
ha2=$(printf 'GET:%s' "$uri" | md5s)
resp=$(printf '%s:%s:00000001:%s:auth:%s' "$ha1" "$nonce" "$cnonce" "$ha2" | md5s)
echo "Digest username=\"$AUTH_USER\", realm=\"$REALM\", nonce=\"$nonce\", uri=\"$uri\", response=\"$resp\", cnonce=\"$cnonce\", nc=00000001, qop=auth, opaque=\"$opaque\""
}

# 1. legitimate digest auth on both endpoints (curl --digest)
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --digest -u "$AUTH_USER:$AUTH_PASS" "$BASE_URL/auth-digest")
check "$STATUS" "200" "1a. legit digest auth /auth-digest"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --digest -u "$AUTH_USER:$AUTH_PASS" "$BASE_URL/auth-digest-hash")
check "$STATUS" "200" "1b. legit digest auth /auth-digest-hash"

# 2. cross-endpoint replay: an Authorization header captured for /auth-digest
# (replayed verbatim: response hash still computed for uri="/auth-digest")
# is accepted by default on /auth-digest-hash, which has the same credentials
# and realm: no raw request-target is kept, so the header uri is not checked.
CH=$(get_challenge /auth-digest)
NONCE=$(extract "$CH" nonce)
OPAQUE=$(extract "$CH" opaque)
AUTH=$(build_auth "$NONCE" "$OPAQUE" "/auth-digest")
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: $AUTH" "$BASE_URL/auth-digest-hash")
check "$STATUS" "200" "2. cross-endpoint replay accepted by default (no uri check)"

# 3. forged nonce must be rejected even with valid credentials and matching uri
CH=$(get_challenge /auth-digest)
NONCE=$(extract "$CH" nonce)
OPAQUE=$(extract "$CH" opaque)
# 40 chars, the exact length of a genuine nonce: the MAC, not the length, must reject it
FORGED="deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
AUTH=$(build_auth "$FORGED" "$OPAQUE" "/auth-digest")
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: $AUTH" "$BASE_URL/auth-digest")
check "$STATUS" "401" "3. forged nonce rejected"

# 4. foreign opaque must be rejected
AUTH=$(build_auth "$NONCE" "00000000000000000000000000000000" "/auth-digest")
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: $AUTH" "$BASE_URL/auth-digest")
check "$STATUS" "401" "4. foreign opaque rejected"

# 5. query string is NOT part of the default validation (no uri check without binding)
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --digest -u "$AUTH_USER:$AUTH_PASS" "$BASE_URL/auth-digest?a=1")
check "$STATUS" "200" "5a. legit digest auth with query string"
CH=$(get_challenge /auth-digest)
NONCE=$(extract "$CH" nonce)
OPAQUE=$(extract "$CH" opaque)
AUTH=$(build_auth "$NONCE" "$OPAQUE" "/auth-digest")
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: $AUTH" "$BASE_URL/auth-digest?a=1")
check "$STATUS" "200" "5b. replay with different query string accepted by default (no uri check)"

# 6. duplicated nonce must be rejected: the first occurrence (read by the stateless
# signature/TTL validation) must not launder a second, different occurrence (over
# which the response hash is computed). Regression test for the first-vs-last nonce
# parser divergence that allowed replaying an expired captured header.
CH=$(get_challenge /auth-digest)
NONCE=$(extract "$CH" nonce)
OPAQUE=$(extract "$CH" opaque)
LEGIT=$(build_auth "$NONCE" "$OPAQUE" "/auth-digest") # valid header, response computed over NONCE
FRESH_NONCE=$(extract "$(get_challenge /auth-digest)" nonce)
DUP_AUTH=${LEGIT/nonce=\"$NONCE\"/nonce=\"$FRESH_NONCE\", nonce=\"$NONCE\"}
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: $DUP_AUTH" "$BASE_URL/auth-digest")
check "$STATUS" "401" "6. duplicated nonce rejected"

# 7. uri binding enforced by the caller (RFC 7616 §3.4.6): the endpoint
# /auth-digest-bound binds the header uri to its own path, via
# request->authenticate(..., "/auth-digest-bound")
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --digest -u "$AUTH_USER:$AUTH_PASS" "$BASE_URL/auth-digest-bound")
check "$STATUS" "200" "7a. legit digest auth with caller-enforced uri binding"

# 8. a header issued for another endpoint must be rejected on the uri-bound endpoint
CH=$(get_challenge /auth-digest)
NONCE=$(extract "$CH" nonce)
OPAQUE=$(extract "$CH" opaque)
AUTH=$(build_auth "$NONCE" "$OPAQUE" "/auth-digest")
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: $AUTH" "$BASE_URL/auth-digest-bound")
check "$STATUS" "401" "8. cross-endpoint replay rejected with caller-enforced uri binding"

echo ""
echo "Results: $NB_PASS passed, $NB_FAIL failed"
[ "$NB_FAIL" -eq 0 ]
2 changes: 1 addition & 1 deletion src/AsyncEventSource.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ static String generateEventMessage(const char *message, const char *event, uint3
}

// a message is a multi-line string
char *nextLine = NULL;
char *nextLine = nullptr;
if (nextN != NULL && nextR != NULL) { // windows line-ending \r\n
if (nextR + 1 == nextN) {
// normal \r\n sequence
Expand Down
4 changes: 2 additions & 2 deletions src/AsyncEventSource.h
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ class AsyncEventSourceClient {
* @return true if message was placed in a queue
* @return false if queue is full
*/
bool send(const char *message, const char *event = NULL, uint32_t id = 0, uint32_t reconnect = 0);
bool send(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0);
bool send(const String &message, const String &event, uint32_t id = 0, uint32_t reconnect = 0) {
return send(message.c_str(), event.c_str(), id, reconnect);
}
Expand Down Expand Up @@ -234,7 +234,7 @@ class AsyncEventSource : public AsyncWebHandler {
* @param reconnect client's reconnect timeout
* @return SendStatus if message was placed in any/all/part of the client's queues
*/
SendStatus send(const char *message, const char *event = NULL, uint32_t id = 0, uint32_t reconnect = 0);
SendStatus send(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0);
SendStatus send(const String &message, const String &event, uint32_t id = 0, uint32_t reconnect = 0) {
return send(message.c_str(), event.c_str(), id, reconnect);
}
Expand Down
14 changes: 7 additions & 7 deletions src/AsyncWebSocket.h
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ class AsyncWebSocketClient {
AwsFrameInfo _pinfo;
std::unique_ptr<uint8_t[]> _pbuffer; // Payload buffer for torn control frames

bool _queueControl(uint8_t opcode, const uint8_t *data = NULL, size_t len = 0, bool mask = false);
bool _queueControl(uint8_t opcode, const uint8_t *data = nullptr, size_t len = 0, bool mask = false);
bool _queueMessage(AsyncWebSocketSharedBuffer buffer, uint8_t opcode = WS_TEXT, bool mask = false);
// caller must hold _queue_lock via `lock`; _runQueue may unlock() it (e.g.
// before a close() that can synchronously destroy *this) and never touch
Expand Down Expand Up @@ -286,8 +286,8 @@ class AsyncWebSocketClient {
}

// control frames
void close(uint16_t code = 0, const char *message = NULL);
bool ping(const uint8_t *data = NULL, size_t len = 0);
void close(uint16_t code = 0, const char *message = nullptr);
bool ping(const uint8_t *data = nullptr, size_t len = 0);

// set auto-ping period in seconds. disabled if zero (default)
void keepAlivePeriod(uint16_t seconds) {
Expand Down Expand Up @@ -379,12 +379,12 @@ class AsyncWebSocket : public AsyncWebHandler {
return client(id) != nullptr;
}

void close(uint32_t id, uint16_t code = 0, const char *message = NULL);
void closeAll(uint16_t code = 0, const char *message = NULL);
void close(uint32_t id, uint16_t code = 0, const char *message = nullptr);
void closeAll(uint16_t code = 0, const char *message = nullptr);
void cleanupClients(uint16_t maxClients = DEFAULT_MAX_WS_CLIENTS);

bool ping(uint32_t id, const uint8_t *data = NULL, size_t len = 0);
SendStatus pingAll(const uint8_t *data = NULL, size_t len = 0); // done
bool ping(uint32_t id, const uint8_t *data = nullptr, size_t len = 0);
SendStatus pingAll(const uint8_t *data = nullptr, size_t len = 0); // done

bool text(uint32_t id, const uint8_t *message, size_t len);
bool text(uint32_t id, const char *message, size_t len);
Expand Down
Loading
Loading