Skip to content

Commit

Permalink
Merge pull request #45 from ingenerator/4.x-feat-json
Browse files Browse the repository at this point in the history
Add utility methods for JSON requests and responses
  • Loading branch information
acoulton authored Sep 23, 2024
2 parents bdfc6aa + 18faf29 commit e5eaa57
Show file tree
Hide file tree
Showing 7 changed files with 407 additions and 0 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ You're really going to want to read this.

## Unreleased

## 4.11.0 (2024-09-23)

* Add `Request::jsonBody` and `Request::jsonBodyArray` for easy access to JSON request bodies.
* Add `Response::setJSON` and `Controller::respondJSON` as helpers for sending JSON responses.

## 4.10.0 (2022-11-09)

* [BEHAVIOUR CHANGE] Kohana no longer overrides the PHP default session_cache_limiter option
Expand Down
10 changes: 10 additions & 0 deletions classes/Kohana/Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,14 @@ protected function check_cache($etag = NULL)
return HTTP::check_cache($this->request, $this->response, $etag);
}

/**
* Sugar method to reduce verbosity of sending a JSON response
*
* @see Response::setJSON()
*/
protected function respondJSON(mixed $body, int $status = 200): void
{
$this->response->setJSON($body, $status);
}

}
97 changes: 97 additions & 0 deletions classes/Kohana/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,103 @@ public function body()
return $this->_body;
}

/**
* Parse a JSON body (of any type, including single scalar values) from the incoming request
*
* Enforces basic security of the maximum body length & depth, to guard against the easiest
* kinds of JSON attacks. These should be set as low as your app can handle for the specific
* request(s) it is parsing. Note that there are other mechanisms for JSON DOS attacks which
* are harder to guard against in userland - you should ideally only use this method for
* requests from authenticated and trusted users to provide additional protection.
*
* Note also that the maximum JSON length is enforced separately from `post_max_size` in your
* PHP config. This is because you will commonly need to accept longer POST bodies for things
* like file uploads and multipart-encoded form submisssions.
*/
public function jsonBody(
int $max_json_bytes = 1000,
int $max_json_depth = 20,
): mixed
{
// First check the content-type - if they've randomly sent the wrong type of content that
// could plausibly also be too big. So we want the error message to be clear this is a
// content type problem.
$content_type = $this->headers('content-type');
if (explode(';', $content_type ?? '', 2)[0] !== 'application/json') {
throw new Request_InvalidJSONRequestException(
$content_type
? "Unexpected content type (got $content_type)"
: 'No content-type specified'
);
}

// Then verify the user-specified content-length is within the allowed max size
// This might be bigger than the actual received payload if e.g. it has been stripped
// due to the post_max_size config.
$content_length = (int) $this->headers('content-length');
if ($content_length > $max_json_bytes) {
throw new Request_InvalidJSONRequestException(
sprintf(
'Content larger than max_json_bytes (got %s)',
Text::bytes($content_length, format: '%01.1f%s')
)
);
}

// Then verify the user-specified content-length matches the actual content we've received
$actual_content_length = strlen($this->_body);
if ($content_length !== $actual_content_length) {
throw new Request_InvalidJSONRequestException(
sprintf(
'Incorrect content-length header: stated %s, got %s',
Text::bytes($content_length, format: '%01.1f%s'),
Text::bytes($actual_content_length, format: '%01.1f%s'),
)
);
}

// OK, it's probably as safe as it can be (the native JSON parser will enforce the max depth)
try {
return json_decode(
$this->_body,
associative: TRUE,
depth: $max_json_depth,
flags: JSON_THROW_ON_ERROR,
);
} catch (JSONException $e) {
throw new Request_InvalidJSONRequestException(
'Invalid JSON: '.$e->getMessage(),
previous: $e
);
}
}

/**
* Parse JSON body from the incoming request where an object / array (`{}`, `[]`) is expected
*
* Adds an additional sanity check over ->jsonBody() to enforce that the incoming request can
* be safely treated as an array e.g. to access keys from it.
*
* @see Request::jsonBody() for more details on use and security implications of this method.
*/
public function jsonBodyArray(
int $max_json_bytes = 1000,
int $max_json_depth = 20,
): array
{
// Just pass all the function args to the underlying jsonBody method for initial parse and validation
$decoded = $this->jsonBody(...get_defined_vars());

// Then enforce that the body was actually an object / array
if (is_array($decoded)) {
return $decoded;
}

throw new Request_InvalidJSONRequestException(
'JSON body not an array or object (got '.get_debug_type($decoded).')'
);
}

/**
* Returns the length of the body for use with
* content header
Expand Down
17 changes: 17 additions & 0 deletions classes/Kohana/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,23 @@ public function body($content = NULL)
return $this;
}

/**
* Set a JSON response with body, content-type header, and HTTP status
*
* @param mixed $body Anything that can be json-serialized
* @param int $status
*
* @return void
* @throws JsonException
* @throws Kohana_Exception if status code is unknown
*/
public function setJSON(mixed $body, int $status = 200): void
{
$this->body(json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
$this->status($status);
$this->headers('Content-Type', 'application/json');
}

/**
* Gets or sets the HTTP protocol. The standard protocol to use
* is `HTTP/1.1`.
Expand Down
6 changes: 6 additions & 0 deletions classes/Request/InvalidJSONRequestException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php

class Request_InvalidJSONRequestException extends UnexpectedValueException
{

}
214 changes: 214 additions & 0 deletions tests/kohana/RequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -605,4 +605,218 @@ public function test_it_trims_recursively()
);
}

public function test_can_return_decoded_json_body()
{
$request = Request::with([
'body' => '"I am JSON"',
'header' => new Http_Header([
'content-type' => 'application/json',
'content-length' => '11',
]),
]);

$this->assertSame('I am JSON', $request->jsonBody());
}

public static function provider_json_body_array(): array
{
return [
'JSON object' => [
'{"foo":"bar"}',
['foo' => 'bar'],
],
'JSON array' => [
'["foo","bar"]',
['foo', 'bar'],
],
];
}

/**
* @dataProvider provider_json_body_array
*/
public function test_can_return_decoded_json_body_array_or_object(string $body, array $expect)
{
$request = Request::with([
'body' => $body,
'header' => new Http_Header([
'content-type' => 'application/json',
'content-length' => strlen($body),
])
]);

$this->assertSame($expect, $request->jsonBodyArray());
}

public function test_accepts_json_with_charset_in_content_type()
{
// strictly speaking, charset should not be included in the header but occasionally client
// libraries include it.
$request = Request::with([
'body' => '"foo"',
'header' => new Http_Header([
'content-type' => 'application/json; charset=utf8',
'content-length' => '5',
]),
]);
$this->assertSame('foo', $request->jsonBody());
}

public static function provider_invalid_json_request(): array
{
return [
'missing content-type' => [
[
'body' => '{"foo": "bar"}',
'header' => [
'content-length' => '14',
],
],
'No content-type specified'
],
'unexpected content-type' => [
[
'body' => '{"foo":"bar"}',
'header' => [
'Content-Type' => 'application/x-www-formdata',
'Content-Length' => '14',
],
],
'Unexpected content type (got application/x-www-formdata)',
],
'invalid JSON' => [
[
'body' => '{unquoted: "bar"}',
'header' => [
'Content-Type' => 'application/json',
'Content-Length' => '17',
],
],
'Invalid JSON: Syntax error'
],
'not an array (for jsonBodyArray)' => [
[
'body' => '"I am a valid JSON string"',
'header' => [
'Content-Type' => 'application/json',
'Content-Length' => '26',
],
],
'JSON body not an array or object (got string)',
],
];
}


/**
* @dataProvider provider_invalid_json_request
*/
public function test_throws_on_invalid_or_unexpected_json_request(array $request, string $expect_msg)
{
$request['header'] = new HTTP_Header($request['header'] ?? []);
$request = Request::with($request);
$this->expectException(Request_InvalidJSONRequestException::class);
$this->expectExceptionMessage($expect_msg);
$request->jsonBodyArray();
}

public static function provider_invalid_json_too_large()
{
return [
'within expected size' => [
'{"foo": "bar"}',
['max_json_bytes' => 14],
['result' => ['foo' => 'bar']],
],
'bigger than expected size' => [
'{"foo": "bar"}',
['max_json_bytes' => 13],
['exception' => 'Content larger than max_json_bytes (got 14.0B)'],
],
'up to 1kB default is OK' => [
'{"fo": "'.str_repeat('a', 990).'"}',
[],
['result' => ['fo' => str_repeat('a', 990)]],
],
'bigger than 1kB default fails' => [
'{"fo": "'.str_repeat('a', 991).'"}',
[],
['exception' => 'Content larger than max_json_bytes (got 1.0kB)'],
],
'within default max depth' => [
'{"1":{"2":{"3":{"4":{"5":{"6":{"7":{"8":{"9":{"10":{"11":{"12":{"13":{"14":{"15":{"16":{"17":{"18":{"19":"ok"}}}}}}}}}}}}}}}}}}}',
[],
['result' => [1=>[2=>[3=>[4=>[5=>[6=>[7=>[8=>[9=>[10=>[11=>[12=>[13=>[14=>[15=>[16=>[17=>[18=>[19=>'ok']]]]]]]]]]]]]]]]]]]],
],
'beyond default max depth' => [
'{"1":{"2":{"3":{"4":{"5":{"6":{"7":{"8":{"9":{"10":{"11":{"12":{"13":{"14":{"15":{"16":{"17":{"18":{"19":{"20":"bad"}}}}}}}}}}}}}}}}}}}}',
[],
['exception' => 'Invalid JSON: Maximum stack depth exceeded'],
],
'custom max depth' => [
'{"1":{"2":{"3":{"4":{"5":{"6":{"7":{"8":{"9":{"10":{"11":{"12":{"13":{"14":{"15":{"16":{"17":{"18":{"19":{"20":"bad"}}}}}}}}}}}}}}}}}}}}',
['max_json_depth' => 21],
['result' => [1=>[2=>[3=>[4=>[5=>[6=>[7=>[8=>[9=>[10=>[11=>[12=>[13=>[14=>[15=>[16=>[17=>[18=>[19=>[20=>'bad']]]]]]]]]]]]]]]]]]]]],
]
];
}

/**
* @dataProvider provider_invalid_json_too_large
*/
public function test_json_body_methods_protect_against_large_or_abusive_payloads(string $body, array $args, array $expect_behaviour)
{
$request = Request::with([
'body' => $body,
'header' => new HTTP_Header([
'content-type' => 'application/json',
'content-length' => strlen($body),
])
]);

$actual_behaviour = [];

try {
$actual_behaviour['result'] = $request->jsonBodyArray(...$args);
} catch (Request_InvalidJSONRequestException $e) {
$actual_behaviour['exception'] = $e->getMessage();
}

$this->assertSame($expect_behaviour, $actual_behaviour);
}

public function test_json_body_methods_protect_against_content_length_tampering()
{
$request = Request::with([
'body' => '{"very_long": "'.str_repeat('a', 1_500_000).'"}',
'header' => new HTTP_Header([
'content-type' => 'application/json',
'content-length' => 152,
])
]);

$this->expectException(Request_InvalidJSONRequestException::class);
$this->expectExceptionMessage('Incorrect content-length header: stated 152.0B, got 1.5MB');
$request->jsonBody();
}

public function test_json_body_methods_cope_with_post_max_size_truncation()
{
// If the incoming body is greater than post_max_size, the body may have been cleared by the server
// in which case we want to go off the content-length header regardless that this is a mismatch
// to the body content.
$request = Request::with([
'body' => '',
'header' => new HTTP_Header([
'content-type' => 'application/json',
'content-length' => '20500000',
])
]);

$this->expectException(Request_InvalidJSONRequestException::class);
$this->expectExceptionMessage('Content larger than max_json_bytes (got 20.5MB)');
$request->jsonBody();
}


} // End Kohana_RequestTest
Loading

0 comments on commit e5eaa57

Please sign in to comment.