Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@
/site
/venv
/build
/examples/arduino/**/*idf_component.yml
/examples/arduino/**/*idf_component.yml.orig
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
46 changes: 46 additions & 0 deletions docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,52 @@ 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.
- **URI binding**: the `uri` field of the `Authorization: Digest ...` header must match the effective
request URI (raw request-target: path **and** query string). A header captured on one endpoint cannot
be replayed against another endpoint, nor against the same endpoint with a different query string.
- **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 **on the exact same request URI only**. For one-shot semantics, keep the
> TTL short and serve Digest-protected endpoints over HTTPS.

For custom validation of every digest parameter, `AsyncWebServerRequest::authenticateDigest()` accepts
optional `nonce`, `opaque` and `uri` arguments — 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, opaque and the effective request URI
request->authenticateDigest("admin", "admin", "MyApp");

// the caller takes over the uri check (nonce/opaque still validated by the library)
request->authenticateDigest("admin", "admin", "MyApp", false, NULL, NULL, "/my/custom/uri");
```

### 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
5 changes: 4 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 that a header
// captured on /auth-digest cannot be replayed on another digest-protected URI
// 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 Down
90 changes: 90 additions & 0 deletions examples/arduino/Auth/test_digest.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/bin/bash

# Digest authentication replay protection test
# Tests the digest endpoints defined in Auth.ino
#
# Usage: ./test_digest.sh [SERVER_IP]

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

echo "Testing Digest auth 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
}

# get_challenge(path): prints "nonce|opaque" of the last received challenge
get_challenge() {
curl -s -i "$BASE_URL$1" | grep -i "^WWW-Authenticate:" | 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=$(echo "$nonce$opaque" | md5 | cut -c1-16)
ha1=$(echo -n "$AUTH_USER:MyApp:$AUTH_PASS" | md5 | cut -d' ' -f1)
ha2=$(echo -n "GET:$uri" | md5 | cut -d' ' -f1)
resp=$(echo -n "$ha1:$nonce:00000001:$cnonce:auth:$ha2" | md5 | cut -d' ' -f1)
echo "Digest username=\"$AUTH_USER\", realm=\"MyApp\", 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")
# must be rejected on /auth-digest-hash, which has the same credentials and realm.
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" "401" "2. cross-endpoint verbatim replay rejected"

# 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)
FORGED="deadbeef00000000000000000000000000000000000000000000000000000000"
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 part of the uri 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" "401" "5b. replay with different query string rejected"

echo ""
echo "Results: $NB_PASS passed, $NB_FAIL failed"
[ "$NB_FAIL" -eq 0 ]
18 changes: 8 additions & 10 deletions src/AsyncWebServerRequest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,6 @@ void AsyncWebServerRequest::send(FS &fs, const String &path, const char *content
* @return true if the ETag was successfully generated, false otherwise (e.g., file too short or seek failed).
*/
bool AsyncWebServerRequest::_getEtag(File gzFile, char *etag) {
static constexpr char hexChars[] = "0123456789ABCDEF";

if (!gzFile.seek(gzFile.size() - 8)) {
return false;
}
Expand All @@ -81,14 +79,14 @@ bool AsyncWebServerRequest::_getEtag(File gzFile, char *etag) {
gzFile.read(reinterpret_cast<uint8_t *>(&crc), sizeof(crc));

etag[0] = '"';
etag[1] = hexChars[(crc >> 4) & 0x0F];
etag[2] = hexChars[crc & 0x0F];
etag[3] = hexChars[(crc >> 12) & 0x0F];
etag[4] = hexChars[(crc >> 8) & 0x0F];
etag[5] = hexChars[(crc >> 20) & 0x0F];
etag[6] = hexChars[(crc >> 16) & 0x0F];
etag[7] = hexChars[(crc >> 28)];
etag[8] = hexChars[(crc >> 24) & 0x0F];
etag[1] = asyncsrv::T_hex_chars[(crc >> 4) & 0x0F];
etag[2] = asyncsrv::T_hex_chars[crc & 0x0F];
etag[3] = asyncsrv::T_hex_chars[(crc >> 12) & 0x0F];
etag[4] = asyncsrv::T_hex_chars[(crc >> 8) & 0x0F];
etag[5] = asyncsrv::T_hex_chars[(crc >> 20) & 0x0F];
etag[6] = asyncsrv::T_hex_chars[(crc >> 16) & 0x0F];
etag[7] = asyncsrv::T_hex_chars[(crc >> 28)];
etag[8] = asyncsrv::T_hex_chars[(crc >> 24) & 0x0F];
etag[9] = '"';
etag[10] = '\0';

Expand Down
2 changes: 2 additions & 0 deletions src/AsyncWebSocket.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1347,6 +1347,7 @@ AsyncWebSocket::SendStatus AsyncWebSocket::binaryAll(AsyncWebSocketSharedBuffer
}

size_t AsyncWebSocket::printf(uint32_t id, const char *format, ...) {
asyncsrv::lock_guard_type lock(_ws_clients_lock);
AsyncWebSocketClient *c = client(id);
if (c) {
va_list arg;
Expand Down Expand Up @@ -1385,6 +1386,7 @@ size_t AsyncWebSocket::printfAll(const char *format, ...) {

#ifdef ESP8266
size_t AsyncWebSocket::printf_P(uint32_t id, PGM_P formatP, ...) {
asyncsrv::lock_guard_type lock(_ws_clients_lock);
AsyncWebSocketClient *c = client(id);
if (c != NULL) {
va_list arg;
Expand Down
98 changes: 98 additions & 0 deletions src/ESPAsyncWebServer.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,17 @@
#endif
#endif

// Validity window, in seconds, of a Digest authentication nonce.
// Statelessness relies on the nonce embedding its issuing time: beyond this window
// the nonce is stale and clients must re-authenticate (silently, via stale=true).
// Set to 0 to disable nonce expiry (not recommended: widens the replay window).
#if !defined(ASYNCWEBSERVER_DIGEST_NONCE_TTL)
#define ASYNCWEBSERVER_DIGEST_NONCE_TTL 60
#endif

#include "AsyncWebServerVersion.h"
#define ASYNCWEBSERVER_FORK_ESP32Async
#include "WebAuthentication.h"

#ifdef ASYNCWEBSERVER_REGEX
#include <regex>
Expand Down Expand Up @@ -549,8 +558,15 @@ class AsyncWebServerRequest {
String _contentType;
String _boundary;
String _authorization;
// Effective request URI (RFC 7230 §5.5): raw request-target — path + query string,
// percent-encoded form as received on the request line, before any rewriting.
// Used to validate the "uri" parameter of Digest authentication.
String _rawUri;
RequestedConnectionType _reqconntype;
AsyncAuthType _authMethod = AsyncAuthType::AUTH_NONE;
// set by authenticateDigest() when the client nonce was valid but expired (RFC 7616 §3.3):
// the next challenge must carry stale=true so compliant clients silently re-authenticate
mutable bool _authStale = false;
bool _isMultipart;
bool _isPlainPost;
bool _expectingContinue;
Expand Down Expand Up @@ -658,6 +674,13 @@ class AsyncWebServerRequest {
const String &url() const {
return _url;
}
// Effective request URI (RFC 7230 §5.5): raw request-target — path + query string
// in the exact percent-encoded form received on the request line, before URL decoding
// and before any rewrite is applied. Useful to build canonical links, audit raw input,
// or implement authentication schemes binding to the request target (e.g. HTTP Digest).
const String &rawUri() const {
return _rawUri;
}
const String &host() const {
return _host;
}
Expand Down Expand Up @@ -707,7 +730,42 @@ class AsyncWebServerRequest {
// base64(user:pass) for basic or
// user:realm:md5(user:realm:pass) for digest
bool authenticate(const char *hash) const;
bool authenticateDigest(const char *hash) const;
bool authenticate(const char *username, const char *credentials, const char *realm = NULL, bool isHash = false) const;

/**
* @brief Digest authentication with server-side anti-replay protection.
*
* Beyond the standard digest verification, this call validates that:
* - the nonce was issued by this server and is not expired (stateless signed nonce);
* - the opaque is the one bound to this server instance;
* - the header "uri" matches the actual request-target (path + query string).
*
* A NULL nonce/opaque/uri argument means "let the library validate it automatically";
* passing a value means the caller takes over that specific check.
*
* @param username user name
* @param credentials password or pre-computed digest hash (see isHash)
* @param realm authentication realm (NULL: not checked)
* @param isHash true if credentials is md5(user:realm:password) instead of the raw password
* @param nonce NULL to validate the server-issued signed nonce, or the exact value to compare against
* @param opaque NULL to validate the instance opaque, or the exact value to compare against
* @param uri NULL to validate against the effective request URI, or the exact value to compare against
* @return true if the request is authenticated
*/
bool authenticateDigest(
const char *username, const char *credentials, const char *realm = NULL, bool isHash = false, const char *nonce = NULL, const char *opaque = NULL,
const char *uri = NULL
) const;
/**
* @brief true if the last authenticateDigest() call failed only because the nonce expired.
*
* When set, requestAuthentication() issues the next challenge with stale=true (RFC 7616 §3.3),
* so compliant clients re-authenticate silently without prompting the user again.
*/
bool authStale() const {
return _authStale;
}
void requestAuthentication(const char *realm = nullptr, bool isDigest = true) {
requestAuthentication(isDigest ? AsyncAuthType::AUTH_DIGEST : AsyncAuthType::AUTH_BASIC, realm);
}
Expand Down Expand Up @@ -1784,6 +1842,8 @@ typedef AsyncCallbackJsonWebHandler AsyncCallbackMessagePackWebHandler;

class AsyncWebServer : public AsyncMiddlewareChain {
protected:
friend class AsyncWebServerRequest;

AsyncServer _server;
std::list<std::shared_ptr<AsyncWebRewrite>> _rewrites;
// Unified handler list: each entry is an asyncsrv::MaybeOwned<AsyncWebHandler>.
Expand All @@ -1794,6 +1854,13 @@ class AsyncWebServer : public AsyncMiddlewareChain {
// A catch-all handler is always installed (never nullptr).
asyncsrv::MaybeOwned<AsyncCallbackWebHandler> _catchAllHandler;

// Stateless Digest auth anti-replay: the nonce embeds
// its issuing time and is HMAC-signed with this per-instance secret, so no
// per-nonce storage is required. The opaque is derived from the same secret.
uint8_t _digestSecret[16] = {0};
uint32_t _digestNonceTtl = ASYNCWEBSERVER_DIGEST_NONCE_TTL * 1000; // milliseconds
String _digestOpaque;

public:
/**
* @brief Construct a server with a default internal catch-all handler.
Expand Down Expand Up @@ -2032,6 +2099,37 @@ class AsyncWebServer : public AsyncMiddlewareChain {
ArBodyHandlerFunction onBody = nullptr
);

/**
* @brief Set the validity window of Digest nonces issued by this server (stateless scheme).
*
* Nonces embed their issuing time and are signed with a per-instance secret; no
* server-side storage is kept. 0 disables expiry (not recommended).
* @param seconds nonce lifetime in seconds (default: ASYNCWEBSERVER_DIGEST_NONCE_TTL, 60s)
*/
void setDigestNonceTimeout(uint32_t seconds) {
_digestNonceTtl = seconds * 1000;
}

/**
* @brief Set a custom secret used to sign Digest nonces and derive the opaque value.
*
* Changing the secret instantly revokes every nonce in circulation (clients will
* be re-challenged and silently re-authenticate). If never called, a random
* per-instance secret is generated at startup.
* @param secret pointer to 16 raw bytes
*/
void setDigestSecret(const uint8_t secret[16]) {
if (secret) {
memcpy(_digestSecret, secret, sizeof(_digestSecret));
_digestOpaque = generateDigestOpaque(_digestSecret);
}
}

/** @brief Digest opaque value derived from the instance secret. */
const String &digestOpaque() const {
return _digestOpaque;
}

#if ASYNC_JSON_SUPPORT == 1
AsyncCallbackJsonWebHandler &on(AsyncURIMatcher uri, WebRequestMethodComposite method, ArJsonRequestHandlerFunction onBody);
#endif
Expand Down
Loading
Loading