oauth: Limit JSON parsing depth in the client
authorJacob Champion <jchampion@postgresql.org>
Fri, 23 May 2025 20:05:33 +0000 (13:05 -0700)
committerJacob Champion <jchampion@postgresql.org>
Fri, 23 May 2025 20:05:33 +0000 (13:05 -0700)
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: Aleksander Alekseev <aleksander@timescale.com>
Discussion: https://postgr.es/m/CAOYmi%2Bm71aRUEi0oQE9ciBnBS8xVtMn3CifaPu2kmJzUfhOZgA%40mail.gmail.com

src/interfaces/libpq-oauth/oauth-curl.c
src/interfaces/libpq/fe-auth-oauth.c
src/test/modules/oauth_validator/t/001_server.pl
src/test/modules/oauth_validator/t/oauth_server.py

index d13b9cbabb4fe63dc377ec6ea4741673594d6f01..dba9a684fa8a57daeb6907c4842991689c8ea958 100644 (file)
  */
 #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;
 }
 
index 9fbff89a21d91fe4299eeefc8a44d3a19f2334ea..d146c5f567c2941d58f51655b16ce17dac6e9c62 100644 (file)
@@ -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);
index bfc9dc3b542226ee76986f3ffbf16fbd55f127aa..41672ebd5c6dc5890407d8566f43945691b15bc4 100644 (file)
@@ -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",
index 20b3a9506cbf4b9ec9a1a92d9d6c8dfa66faf7c1..0f8836aadf3729980813214b7668fc18bb9af043 100755 (executable)
@@ -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):