[PATCH] oauth: Prevent stack overflow by limiting JSON parse depth
Hi all,
I forgot to put a recursion limit in the new OAuth parsers; the
server-side depth checks don't apply to the client, and it's not using
the incremental parser to move the burden from the stack to the heap.
Luckily, we track the nesting level already, so a fix (attached) can
be pretty small.
(Eventually I'd like to set a limit via the client-side JSONAPI, or
maybe port the server's stack checks? That's 19 material.)
Enforcing a limit is relatively easy, but choosing the limit is
somewhat harder, because OAuth will continue to evolve and add
extensions that we're supposed to gracefully ignore. The most typical
depth I've seen in practice is 2 (an object containing arrays of
strings). I took a look through the IANA registry [1]https://www.iana.org/assignments/oauth-parameters/oauth-parameters.xhtml#authorization-server-metadata, and I think the
maximum nesting depth we can get today is if someone plops an entire
JSON Web Key Set into their discovery document. That could bring the
depth up to 6 with some of the newer key types.
Hopefully the extension depth doesn't continue to grow unbounded, but
it's easy for spec writers to find ways to nest new stuff, so I've
chosen a max depth of 16. Probably overkill. Hopefully overkill?
For the SASL parser, which is only speaking OAUTHBEARER to a Postgres
server/proxy, I think we can be way more strict. We expect a nesting
depth of only 1 there. I've put in a limit of 8, to try to avoid a
backport in the case that some nested extension is published and
suddenly gains popularity, but I could easily be talked downwards.
And if we ever have to revisit these limits, I think that would be
reason enough to switch from the recursive descent parser over to the
incremental parser, anyway.
= Postmortem =
As an aside: I was curious why libfuzzer didn't catch this, since I've
been using it as a safety net. The answer was a depressingly simple
"you ignored a printed warning":
INFO: -max_len is not provided; libFuzzer will not generate inputs
larger than 4096 bytes
It looks like this default limit is variable, based on the corpus you
already have, and 4K is not enough to run my machine out of stack. But
setting it explicitly to our response limit of 256K allows libfuzzer
to find this issue very quickly, and I will do that going forward.
(If anyone has run libfuzzer before and has a "favorite
configuration", I'd love to hear it!)
Thanks,
--Jacob
Attachments:
0001-oauth-Limit-JSON-parsing-depth-in-the-client.patchapplication/octet-stream; name=0001-oauth-Limit-JSON-parsing-depth-in-the-client.patchDownload
From 666fba8cbfce4e77e1cb3d5de468a45b7a02a440 Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Tue, 29 Apr 2025 15:44:16 -0700
Subject: [PATCH] oauth: Limit JSON parsing depth in the client
Check the ctx->nested level as we go, to prevent a server from running
the client out of stack space.
The limit we choose when communicating with authorization servers can't
be overly strict, since those servers will continue to add extensions in
their JSON documents which we need to correctly ignore. For the SASL
communication, we can be more conservative, since there are no defined
extensions (and the peer is probably more Postgres code).
Reviewed-by: TODO
---
src/interfaces/libpq-oauth/oauth-curl.c | 26 ++++++++++++++++
src/interfaces/libpq/fe-auth-oauth.c | 25 +++++++++++++++
.../modules/oauth_validator/t/001_server.pl | 20 ++++++++++++
.../modules/oauth_validator/t/oauth_server.py | 31 +++++++++++++++----
4 files changed, 96 insertions(+), 6 deletions(-)
diff --git a/src/interfaces/libpq-oauth/oauth-curl.c b/src/interfaces/libpq-oauth/oauth-curl.c
index d13b9cbabb4..dba9a684fa8 100644
--- a/src/interfaces/libpq-oauth/oauth-curl.c
+++ b/src/interfaces/libpq-oauth/oauth-curl.c
@@ -82,6 +82,20 @@
*/
#define MAX_OAUTH_RESPONSE_SIZE (256 * 1024)
+/*
+ * Similarly, a limit on the maximum JSON nesting level keeps a server from
+ * running us out of stack space. A common nesting level in practice is 2 (for a
+ * top-level object containing arrays of strings). As of May 2025, the maximum
+ * depth for standard server metadata appears to be 6, if the document contains
+ * a full JSON Web Key Set in its "jwks" parameter.
+ *
+ * Since it's easy to nest JSON, and the number of parameters and key types
+ * keeps growing, take a healthy buffer of 16. (If this ever proves to be a
+ * problem in practice, we may want to switch over to the incremental JSON
+ * parser instead of playing with this parameter.)
+ */
+#define MAX_OAUTH_NESTING_LEVEL 16
+
/*
* Parsed JSON Representations
*
@@ -495,6 +509,12 @@ oauth_json_object_start(void *state)
}
++ctx->nested;
+ if (ctx->nested > MAX_OAUTH_NESTING_LEVEL)
+ {
+ oauth_parse_set_error(ctx, "JSON is too deeply nested");
+ return JSON_SEM_ACTION_FAILED;
+ }
+
return JSON_SUCCESS;
}
@@ -599,6 +619,12 @@ oauth_json_array_start(void *state)
}
++ctx->nested;
+ if (ctx->nested > MAX_OAUTH_NESTING_LEVEL)
+ {
+ oauth_parse_set_error(ctx, "JSON is too deeply nested");
+ return JSON_SEM_ACTION_FAILED;
+ }
+
return JSON_SUCCESS;
}
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
index 9fbff89a21d..d146c5f567c 100644
--- a/src/interfaces/libpq/fe-auth-oauth.c
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -157,6 +157,14 @@ client_initial_response(PGconn *conn, bool discover)
#define ERROR_SCOPE_FIELD "scope"
#define ERROR_OPENID_CONFIGURATION_FIELD "openid-configuration"
+/*
+ * Limit the maximum number of nested objects/arrays. Because OAUTHBEARER
+ * doesn't have any defined extensions for its JSON yet, we can be much more
+ * conservative here than with libpq-oauth's MAX_OAUTH_NESTING_LEVEL; we expect
+ * a nesting level of 1 in practice.
+ */
+#define MAX_SASL_NESTING_LEVEL 8
+
struct json_ctx
{
char *errmsg; /* any non-NULL value stops all processing */
@@ -196,6 +204,9 @@ oauth_json_object_start(void *state)
}
++ctx->nested;
+ if (ctx->nested > MAX_SASL_NESTING_LEVEL)
+ oauth_json_set_error(ctx, libpq_gettext("JSON is too deeply nested"));
+
return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
}
@@ -254,9 +265,22 @@ oauth_json_array_start(void *state)
ctx->target_field_name);
}
+ ++ctx->nested;
+ if (ctx->nested > MAX_SASL_NESTING_LEVEL)
+ oauth_json_set_error(ctx, libpq_gettext("JSON is too deeply nested"));
+
return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
}
+static JsonParseErrorType
+oauth_json_array_end(void *state)
+{
+ struct json_ctx *ctx = state;
+
+ --ctx->nested;
+ return JSON_SUCCESS;
+}
+
static JsonParseErrorType
oauth_json_scalar(void *state, char *token, JsonTokenType type)
{
@@ -519,6 +543,7 @@ handle_oauth_sasl_error(PGconn *conn, const char *msg, int msglen)
sem.object_end = oauth_json_object_end;
sem.object_field_start = oauth_json_object_field_start;
sem.array_start = oauth_json_array_start;
+ sem.array_end = oauth_json_array_end;
sem.scalar = oauth_json_scalar;
err = pg_parse_json(lex, &sem);
diff --git a/src/test/modules/oauth_validator/t/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
index 4f035417a40..f25b4089091 100644
--- a/src/test/modules/oauth_validator/t/001_server.pl
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -295,6 +295,26 @@ $node->connect_fails(
expected_stderr =>
qr/failed to obtain access token: response is too large/);
+my $nesting_limit = 16;
+$node->connect_ok(
+ connstr(
+ stage => 'device',
+ nested_array => $nesting_limit,
+ nested_object => $nesting_limit),
+ "nested arrays and objects, up to parse limit",
+ expected_stderr =>
+ qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_fails(
+ connstr(stage => 'device', nested_array => $nesting_limit + 1),
+ "bad discovery response: overly nested JSON array",
+ expected_stderr =>
+ qr/failed to parse device authorization: JSON is too deeply nested/);
+$node->connect_fails(
+ connstr(stage => 'device', nested_object => $nesting_limit + 1),
+ "bad discovery response: overly nested JSON object",
+ expected_stderr =>
+ qr/failed to parse device authorization: JSON is too deeply nested/);
+
$node->connect_fails(
connstr(stage => 'device', content_type => 'text/plain'),
"bad device authz response: wrong content type",
diff --git a/src/test/modules/oauth_validator/t/oauth_server.py b/src/test/modules/oauth_validator/t/oauth_server.py
index 20b3a9506cb..0f8836aadf3 100755
--- a/src/test/modules/oauth_validator/t/oauth_server.py
+++ b/src/test/modules/oauth_validator/t/oauth_server.py
@@ -7,6 +7,7 @@
#
import base64
+import functools
import http.server
import json
import os
@@ -213,14 +214,32 @@ class OAuthHandler(http.server.BaseHTTPRequestHandler):
@property
def _response_padding(self):
"""
- If the huge_response test parameter is set to True, returns a dict
- containing a gigantic string value, which can then be folded into a JSON
- response.
+ Returns a dict with any additional entries that should be folded into a
+ JSON response, as determined by test parameters provided by the client:
+
+ - huge_response: if set to True, the dict will contain a gigantic string
+ value
+
+ - nested_array: if set to nonzero, the dict will contain a deeply nested
+ array so that the top-level object has the given depth
+
+ - nested_object: if set to nonzero, the dict will contain a deeply
+ nested JSON object so that the top-level object has the given depth
"""
- if not self._get_param("huge_response", False):
- return dict()
+ ret = dict()
+
+ if self._get_param("huge_response", False):
+ ret["_pad_"] = "x" * 1024 * 1024
+
+ depth = self._get_param("nested_array", 0)
+ if depth:
+ ret["_arr_"] = functools.reduce(lambda x, _: [x], range(depth))
+
+ depth = self._get_param("nested_object", 0)
+ if depth:
+ ret["_obj_"] = functools.reduce(lambda x, _: {"": x}, range(depth))
- return {"_pad_": "x" * 1024 * 1024}
+ return ret
@property
def _access_token(self):
--
2.34.1
Hi Jacob,
I forgot to put a recursion limit in the new OAuth parsers; the
server-side depth checks don't apply to the client, and it's not using
the incremental parser to move the burden from the stack to the heap.
Luckily, we track the nesting level already, so a fix (attached) can
be pretty small.[...]
Thanks for the patch. It looks good to me. It's well documented and
covered with tests. I can confirm that the tests pass. Also they fail
if I decrease the $nesting_limit value to 15.
--
Best regards,
Aleksander Alekseev
On Thu, May 8, 2025 at 5:22 AM Aleksander Alekseev
<aleksander@timescale.com> wrote:
Thanks for the patch. It looks good to me. It's well documented and
covered with tests. I can confirm that the tests pass. Also they fail
if I decrease the $nesting_limit value to 15.
Thanks for the review!
--Jacob