Skip to content

Commit cbc8fd0

Browse files
committed
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: Aleksander Alekseev <aleksander@timescale.com> Discussion: https://postgr.es/m/CAOYmi%2Bm71aRUEi0oQE9ciBnBS8xVtMn3CifaPu2kmJzUfhOZgA%40mail.gmail.com
1 parent 1ca583f commit cbc8fd0

File tree

4 files changed

+96
-6
lines changed

4 files changed

+96
-6
lines changed

src/interfaces/libpq-oauth/oauth-curl.c

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@
8282
*/
8383
#define MAX_OAUTH_RESPONSE_SIZE (256 * 1024)
8484

85+
/*
86+
* Similarly, a limit on the maximum JSON nesting level keeps a server from
87+
* running us out of stack space. A common nesting level in practice is 2 (for a
88+
* top-level object containing arrays of strings). As of May 2025, the maximum
89+
* depth for standard server metadata appears to be 6, if the document contains
90+
* a full JSON Web Key Set in its "jwks" parameter.
91+
*
92+
* Since it's easy to nest JSON, and the number of parameters and key types
93+
* keeps growing, take a healthy buffer of 16. (If this ever proves to be a
94+
* problem in practice, we may want to switch over to the incremental JSON
95+
* parser instead of playing with this parameter.)
96+
*/
97+
#define MAX_OAUTH_NESTING_LEVEL 16
98+
8599
/*
86100
* Parsed JSON Representations
87101
*
@@ -495,6 +509,12 @@ oauth_json_object_start(void *state)
495509
}
496510

497511
++ctx->nested;
512+
if (ctx->nested > MAX_OAUTH_NESTING_LEVEL)
513+
{
514+
oauth_parse_set_error(ctx, "JSON is too deeply nested");
515+
return JSON_SEM_ACTION_FAILED;
516+
}
517+
498518
return JSON_SUCCESS;
499519
}
500520

@@ -599,6 +619,12 @@ oauth_json_array_start(void *state)
599619
}
600620

601621
++ctx->nested;
622+
if (ctx->nested > MAX_OAUTH_NESTING_LEVEL)
623+
{
624+
oauth_parse_set_error(ctx, "JSON is too deeply nested");
625+
return JSON_SEM_ACTION_FAILED;
626+
}
627+
602628
return JSON_SUCCESS;
603629
}
604630

src/interfaces/libpq/fe-auth-oauth.c

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,14 @@ client_initial_response(PGconn *conn, bool discover)
157157
#define ERROR_SCOPE_FIELD "scope"
158158
#define ERROR_OPENID_CONFIGURATION_FIELD "openid-configuration"
159159

160+
/*
161+
* Limit the maximum number of nested objects/arrays. Because OAUTHBEARER
162+
* doesn't have any defined extensions for its JSON yet, we can be much more
163+
* conservative here than with libpq-oauth's MAX_OAUTH_NESTING_LEVEL; we expect
164+
* a nesting level of 1 in practice.
165+
*/
166+
#define MAX_SASL_NESTING_LEVEL 8
167+
160168
struct json_ctx
161169
{
162170
char *errmsg; /* any non-NULL value stops all processing */
@@ -196,6 +204,9 @@ oauth_json_object_start(void *state)
196204
}
197205

198206
++ctx->nested;
207+
if (ctx->nested > MAX_SASL_NESTING_LEVEL)
208+
oauth_json_set_error(ctx, libpq_gettext("JSON is too deeply nested"));
209+
199210
return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
200211
}
201212

@@ -254,9 +265,22 @@ oauth_json_array_start(void *state)
254265
ctx->target_field_name);
255266
}
256267

268+
++ctx->nested;
269+
if (ctx->nested > MAX_SASL_NESTING_LEVEL)
270+
oauth_json_set_error(ctx, libpq_gettext("JSON is too deeply nested"));
271+
257272
return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
258273
}
259274

275+
static JsonParseErrorType
276+
oauth_json_array_end(void *state)
277+
{
278+
struct json_ctx *ctx = state;
279+
280+
--ctx->nested;
281+
return JSON_SUCCESS;
282+
}
283+
260284
static JsonParseErrorType
261285
oauth_json_scalar(void *state, char *token, JsonTokenType type)
262286
{
@@ -519,6 +543,7 @@ handle_oauth_sasl_error(PGconn *conn, const char *msg, int msglen)
519543
sem.object_end = oauth_json_object_end;
520544
sem.object_field_start = oauth_json_object_field_start;
521545
sem.array_start = oauth_json_array_start;
546+
sem.array_end = oauth_json_array_end;
522547
sem.scalar = oauth_json_scalar;
523548

524549
err = pg_parse_json(lex, &sem);

src/test/modules/oauth_validator/t/001_server.pl

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,26 @@ sub connstr
295295
expected_stderr =>
296296
qr/failed to obtain access token: response is too large/);
297297

298+
my $nesting_limit = 16;
299+
$node->connect_ok(
300+
connstr(
301+
stage => 'device',
302+
nested_array => $nesting_limit,
303+
nested_object => $nesting_limit),
304+
"nested arrays and objects, up to parse limit",
305+
expected_stderr =>
306+
qr@Visit https://example\.com/ and enter the code: postgresuser@);
307+
$node->connect_fails(
308+
connstr(stage => 'device', nested_array => $nesting_limit + 1),
309+
"bad discovery response: overly nested JSON array",
310+
expected_stderr =>
311+
qr/failed to parse device authorization: JSON is too deeply nested/);
312+
$node->connect_fails(
313+
connstr(stage => 'device', nested_object => $nesting_limit + 1),
314+
"bad discovery response: overly nested JSON object",
315+
expected_stderr =>
316+
qr/failed to parse device authorization: JSON is too deeply nested/);
317+
298318
$node->connect_fails(
299319
connstr(stage => 'device', content_type => 'text/plain'),
300320
"bad device authz response: wrong content type",

src/test/modules/oauth_validator/t/oauth_server.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#
88

99
import base64
10+
import functools
1011
import http.server
1112
import json
1213
import os
@@ -213,14 +214,32 @@ def _uri_spelling(self) -> str:
213214
@property
214215
def _response_padding(self):
215216
"""
216-
If the huge_response test parameter is set to True, returns a dict
217-
containing a gigantic string value, which can then be folded into a JSON
218-
response.
217+
Returns a dict with any additional entries that should be folded into a
218+
JSON response, as determined by test parameters provided by the client:
219+
220+
- huge_response: if set to True, the dict will contain a gigantic string
221+
value
222+
223+
- nested_array: if set to nonzero, the dict will contain a deeply nested
224+
array so that the top-level object has the given depth
225+
226+
- nested_object: if set to nonzero, the dict will contain a deeply
227+
nested JSON object so that the top-level object has the given depth
219228
"""
220-
if not self._get_param("huge_response", False):
221-
return dict()
229+
ret = dict()
230+
231+
if self._get_param("huge_response", False):
232+
ret["_pad_"] = "x" * 1024 * 1024
233+
234+
depth = self._get_param("nested_array", 0)
235+
if depth:
236+
ret["_arr_"] = functools.reduce(lambda x, _: [x], range(depth))
237+
238+
depth = self._get_param("nested_object", 0)
239+
if depth:
240+
ret["_obj_"] = functools.reduce(lambda x, _: {"": x}, range(depth))
222241

223-
return {"_pad_": "x" * 1024 * 1024}
242+
return ret
224243

225244
@property
226245
def _access_token(self):

0 commit comments

Comments
 (0)