Skip to content
Open
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
3 changes: 2 additions & 1 deletion docs/guide/rest-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ For example, the above code is roughly equivalent to the following rules:
'DELETE users/<id>' => 'user/delete',
'GET,HEAD users/<id>' => 'user/view',
'POST users' => 'user/create',
'GET,HEAD users' => 'user/index',
'GET,HEAD,QUERY users' => 'user/index',
'users/<id>' => 'user/options',
'users' => 'user/options',
]
Expand All @@ -41,6 +41,7 @@ And the following API endpoints are supported by this rule:

* `GET /users`: list all users page by page;
* `HEAD /users`: show the overview information of user listing;
* `QUERY /users`: list all users page by page, with the filter parameters sent in the request body;
* `POST /users`: create a new user;
* `GET /users/123`: return the details of the user 123;
* `HEAD /users/123`: show the overview information of user 123;
Expand Down
1 change: 1 addition & 0 deletions framework/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Yii Framework 2 Change Log
- Bug #21047: Fix PHPDoc annotations in `Theme`, `AccessRule` and `View` (mspirkov)
- Bug #20217: Apply `ActiveForm::$validationDelay` only while the user is typing, so validation on blur, change and manual trigger is no longer delayed (veksa)
- Bug #19865: Ignore validators with a `when` condition while `AttributeTypecastBehavior` detects `attributeTypes` automatically (veksa)
- Enh #21062: Add support for the HTTP `QUERY` method (RFC 10008) (sanya-misharin)


2.0.55 May 09, 2026
Expand Down
9 changes: 9 additions & 0 deletions framework/UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ Upgrade from Yii 2.0.55
The detection result is composed once per owner class, so such a condition can not be resolved there, and an
attribute covered by conditional rules only is now left out of the map instead of being type-casted according
to the first matching rule. Set `attributeTypes` explicitly if you rely on those attributes being type-casted.
* The HTTP `QUERY` method (RFC 10008) is now treated as safe: it is part of `yii\web\Request::$csrfTokenSafeMethods`,
so a `QUERY` request no longer requires a CSRF token, and `yii\filters\HttpCache` handles it like `GET` and `HEAD`.
It is also routed to the `index` action by `yii\rest\UrlRule`, allowed there by `yii\rest\ActiveController::verbs()`,
and listed in the default `Access-Control-Request-Method` of `yii\filters\Cors`. Being a safe method, `QUERY` is
no longer accepted through `yii\web\Request::$methodParam` either, the same way `GET`, `HEAD` and `OPTIONS` are
already refused there: a `POST` carrying `_method=QUERY` stays a `POST`. The three defaults are separate switches:
removing `QUERY` from `csrfTokenSafeMethods` brings CSRF validation back for it, removing it from `verbs()` makes
`yii\filters\VerbFilter` answer `405`, and removing it from the `Cors` configuration only withholds cross-origin
authorization from browsers instead of rejecting the verb.

Upgrade from Yii 2.0.53
-----------------------
Expand Down
2 changes: 1 addition & 1 deletion framework/filters/Cors.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ class Cors extends ActionFilter
*/
public $cors = [
'Origin' => ['*'],
'Access-Control-Request-Method' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
'Access-Control-Request-Method' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'QUERY'],
'Access-Control-Request-Headers' => ['*'],
'Access-Control-Allow-Credentials' => null,
'Access-Control-Max-Age' => 86400,
Expand Down
2 changes: 1 addition & 1 deletion framework/filters/HttpCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ public function beforeAction($action)
}

$verb = Yii::$app->getRequest()->getMethod();
if ($verb !== 'GET' && $verb !== 'HEAD' || $this->lastModified === null && $this->etagSeed === null) {
if ($verb !== 'GET' && $verb !== 'HEAD' && $verb !== 'QUERY' || $this->lastModified === null && $this->etagSeed === null) {
return true;
}

Expand Down
2 changes: 1 addition & 1 deletion framework/rest/ActiveController.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ public function actions()
protected function verbs()
{
return [
'index' => ['GET', 'HEAD'],
'index' => ['GET', 'HEAD', 'QUERY'],
'view' => ['GET', 'HEAD'],
'create' => ['POST'],
'update' => ['PUT', 'PATCH'],
Expand Down
2 changes: 1 addition & 1 deletion framework/rest/OptionsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class OptionsAction extends BaseAction
/**
* @var array the HTTP verbs that are supported by the collection URL
*/
public $collectionOptions = ['GET', 'POST', 'HEAD', 'OPTIONS'];
public $collectionOptions = ['GET', 'POST', 'HEAD', 'OPTIONS', 'QUERY'];
/**
* @var array the HTTP verbs that are supported by the resource URL
*/
Expand Down
6 changes: 3 additions & 3 deletions framework/rest/UrlRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
* - `'DELETE users/<id>' => 'user/delete'`: delete a user
* - `'GET,HEAD users/<id>' => 'user/view'`: return the details/overview/options of a user
* - `'POST users' => 'user/create'`: create a new user
* - `'GET,HEAD users' => 'user/index'`: return a list/overview/options of users
* - `'GET,HEAD,QUERY users' => 'user/index'`: return a list/overview/options of users
* - `'users/<id>' => 'user/options'`: process all unhandled verbs of a user
* - `'users' => 'user/options'`: process all unhandled verbs of user collection
*
Expand Down Expand Up @@ -122,7 +122,7 @@ class UrlRule extends CompositeUrlRule
'DELETE {id}' => 'delete',
'GET,HEAD {id}' => 'view',
'POST' => 'create',
'GET,HEAD' => 'index',
'GET,HEAD,QUERY' => 'index',
'{id}' => 'options',
'' => 'options',
];
Expand Down Expand Up @@ -194,7 +194,7 @@ protected function createRules()
*/
protected function createRule($pattern, $prefix, $action)
{
$verbs = 'GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS';
$verbs = 'GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|QUERY';
if (preg_match("/^((?:($verbs),)*($verbs))(?:\\s+(.*))?$/", $pattern, $matches)) {
$verbs = explode(',', $matches[1]);
$pattern = isset($matches[4]) ? $matches[4] : '';
Expand Down
2 changes: 1 addition & 1 deletion framework/web/GroupUrlRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ protected function createRules()
$rules = [];
foreach ($this->rules as $key => $rule) {
if (!is_array($rule)) {
$verbs = 'GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS';
$verbs = 'GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|QUERY';
$verb = null;
if (preg_match("/^((?:(?:$verbs),)*(?:$verbs))\\s+(.*)$/", $key, $matches)) {
$verb = explode(',', $matches[1]);
Expand Down
19 changes: 15 additions & 4 deletions framework/web/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
* @property-read bool $isPjax Whether this is a PJAX request.
* @property-read bool $isPost Whether this is a POST request.
* @property-read bool $isPut Whether this is a PUT request.
* @property-read bool $isQuery Whether this is a QUERY request.
* @property-read bool $isSecureConnection If the request is sent via secure channel (https).
* @property-read string $method Request method, such as GET, POST, HEAD, PUT, PATCH, DELETE. The value
* returned is turned into upper case.
Expand Down Expand Up @@ -133,7 +134,7 @@ class Request extends \yii\base\Request
* This property is used only when [[enableCsrfValidation]] is true.
* @see https://datatracker.ietf.org/doc/html/rfc9110#name-safe-methods
*/
public $csrfTokenSafeMethods = ['GET', 'HEAD', 'OPTIONS'];
public $csrfTokenSafeMethods = ['GET', 'HEAD', 'OPTIONS', 'QUERY'];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* @var array "unsafe" methods not triggered a CORS-preflight request
* This property is used only when both [[enableCsrfValidation]] and [[validateCsrfHeaderOnly]] are true.
Expand Down Expand Up @@ -436,8 +437,8 @@ public function getMethod()
if (
isset($_POST[$this->methodParam])
// Never allow to downgrade request from WRITE methods (POST, PATCH, DELETE, etc)
// to read methods (GET, HEAD, OPTIONS) for security reasons.
&& !in_array(strtoupper($_POST[$this->methodParam]), ['GET', 'HEAD', 'OPTIONS'], true)
// to read methods (GET, HEAD, OPTIONS, QUERY) for security reasons.
&& !in_array(strtoupper($_POST[$this->methodParam]), ['GET', 'HEAD', 'OPTIONS', 'QUERY'], true)
) {
return strtoupper($_POST[$this->methodParam]);
}
Expand Down Expand Up @@ -516,6 +517,16 @@ public function getIsPatch()
return $this->getMethod() === 'PATCH';
}

/**
* Returns whether this is a QUERY request.
* @return bool whether this is a QUERY request.
* @since 2.0.56
*/
public function getIsQuery()
{
return $this->getMethod() === 'QUERY';
}

/**
* Returns whether this is an AJAX (XMLHttpRequest) request.
*
Expand Down Expand Up @@ -1879,7 +1890,7 @@ protected function createCsrfCookie($token)
* This method is mainly called in [[Controller::beforeAction()]].
*
* Note that the method will NOT perform CSRF validation if [[enableCsrfValidation]] is false or the HTTP method
* is among GET, HEAD or OPTIONS.
* is among [[csrfTokenSafeMethods]].
*
* @param string|null $clientSuppliedToken the user-provided CSRF token to be validated. If null, the token will be retrieved from
* the [[csrfParam]] POST field or HTTP header.
Expand Down
4 changes: 2 additions & 2 deletions framework/web/UrlManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class UrlManager extends Component
* For example, `'PUT post/<id:\d+>' => 'post/update'`.
* You may specify multiple verbs by separating them with comma
* like this: `'POST,PUT post/index' => 'post/create'`.
* The supported verbs in the shortcut format are: GET, HEAD, POST, PUT, PATCH and DELETE.
* The supported verbs in the shortcut format are: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS and QUERY.
* Note that [[UrlRule::mode|mode]] will be set to PARSING_ONLY when specifying verb in this way
* so you normally would not specify a verb for normal GET request.
*
Expand Down Expand Up @@ -233,7 +233,7 @@ protected function buildRules($ruleDeclarations)
}

$builtRules = [];
$verbs = 'GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS';
$verbs = 'GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|QUERY';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
foreach ($ruleDeclarations as $key => $rule) {
if (is_string($rule)) {
$rule = ['route' => $rule];
Expand Down
18 changes: 18 additions & 0 deletions tests/framework/filters/CorsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,24 @@ public function testPreflight(): void
$this->assertTrue($cors->beforeAction($action));
}

public function testQueryMethodIsAllowedByDefault(): void
{
$this->mockWebApplication();
$controller = new Controller('id', Yii::$app);
$action = new Action('test', $controller);
$request = new Request();

$cors = new Cors();
$cors->request = $request;

$_SERVER['REQUEST_METHOD'] = 'OPTIONS';
$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'] = 'QUERY';
$request->headers->set('Access-Control-Request-Method', 'QUERY');

$this->assertFalse($cors->beforeAction($action));
$this->assertStringContainsString('QUERY', $cors->response->getHeaders()->get('Access-Control-Allow-Methods'));
}

public function testWildcardOrigin(): void
{
$this->mockWebApplication();
Expand Down
19 changes: 19 additions & 0 deletions tests/framework/filters/HttpCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,25 @@ public function testEmptyPragma(): void
$this->assertNotSame($response->getHeaders()->get('Pragma'), '');
}

public function testQueryMethodIsCached(): void
{
$_SERVER['REQUEST_METHOD'] = 'QUERY';

$httpCache = new HttpCache();
$httpCache->etagSeed = function ($action, $params) {
return 'foo';
};

$this->assertTrue($httpCache->beforeAction(null));

$etag = Yii::$app->getResponse()->getHeaders()->get('Etag');
$this->assertNotNull($etag);

Yii::$app->getRequest()->headers->set('If-None-Match', $etag);
$this->assertFalse($httpCache->beforeAction(null));
$this->assertSame(304, Yii::$app->getResponse()->getStatusCode());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* @covers \yii\filters\HttpCache::validateCache
*/
Expand Down
15 changes: 15 additions & 0 deletions tests/framework/rest/UrlRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,21 @@ public function testParseRequest(): void
}
}

public function testParseRequestWithQueryMethod(): void
{
$manager = new UrlManager(['cache' => null]);
$request = new Request(['hostInfo' => 'http://en.example.com']);
$rule = new UrlRule(['controller' => 'post']);

$_SERVER['REQUEST_METHOD'] = 'QUERY';

$request->pathInfo = 'posts';
$this->assertEquals(['post/index', []], $rule->parseRequest($manager, $request));

$request->pathInfo = 'posts/123';
$this->assertEquals(['post/options', ['id' => '123']], $rule->parseRequest($manager, $request));
}

protected function getTestsForParseRequest()
{
// structure of each test
Expand Down
12 changes: 12 additions & 0 deletions tests/framework/web/GroupUrlRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,18 @@ public function testParseVerb(): void
$this->assertContains('POST', $rules->rules[0]->verb);
$this->assertContains('GET', $rules->rules[0]->verb);
$this->assertEquals('admin/user/login', $rules->rules[0]->route);

$config = [
'prefix' => 'admin',
'rules' => [
'QUERY search' => 'user/search'
],
];
$rules = new GroupUrlRule($config);
$this->assertInstanceOf(UrlRule::class, $rules->rules[0]);
$this->assertCount(1, $rules->rules[0]->verb);
$this->assertContains('QUERY', $rules->rules[0]->verb);
$this->assertEquals('admin/user/search', $rules->rules[0]->route);
}

protected function getTestsForCreateUrl()
Expand Down
30 changes: 30 additions & 0 deletions tests/framework/web/RequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,24 @@ public function testCustomSafeMethodsCsrfTokenValidation(): void
}
}

public function testQueryMethodCsrfTokenValidation(): void
{
$this->mockWebApplication();

$request = new Request();
$request->enableCsrfCookie = false;
$request->enableCsrfValidation = true;

$token = $request->getCsrfToken();

$_SERVER['REQUEST_METHOD'] = 'QUERY';

$this->assertTrue($request->validateCsrfToken($token));
$this->assertTrue($request->validateCsrfToken($token . 'a'));
$this->assertTrue($request->validateCsrfToken(null));
$this->assertTrue($request->validateCsrfToken());
}

public function testCsrfHeaderValidation(): void
{
$this->mockWebApplication();
Expand Down Expand Up @@ -1014,6 +1032,17 @@ public function testGetMethod($server, $expected): void
$_SERVER = $original;
}

public function testGetIsQuery(): void
{
$request = new Request();

$_SERVER['REQUEST_METHOD'] = 'QUERY';
$this->assertTrue($request->getIsQuery());

$_SERVER['REQUEST_METHOD'] = 'GET';
$this->assertFalse($request->getIsQuery());
}

public static function getIsAjaxDataProvider(): array
{
return [
Expand Down Expand Up @@ -1261,6 +1290,7 @@ public function testTrustedHostAndXForwardedPort($remoteAddress, $requestPort, $
* @testWith ["POST", "GET", "POST"]
* ["POST", "OPTIONS", "POST"]
* ["POST", "HEAD", "POST"]
* ["POST", "QUERY", "POST"]
* ["POST", "DELETE", "DELETE"]
* ["POST", "CUSTOM", "CUSTOM"]
*/
Expand Down
24 changes: 24 additions & 0 deletions tests/framework/web/UrlManagerParseUrlTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,30 @@ public function testSimpleRulesWithSuffixStrict($suffix): void

// TODO implement with hostinfo

public function testParseQueryMethodRequest(): void
{
$request = new Request();

$manager = new UrlManager([
'enablePrettyUrl' => true,
'enableStrictParsing' => true,
'showScriptName' => false,
'cache' => null,
'rules' => [
'QUERY posts' => 'post/search',
],
]);

$_SERVER['REQUEST_METHOD'] = 'QUERY';
$request->pathInfo = 'posts';
$this->assertEquals(['post/search', []], $manager->parseRequest($request));

$_SERVER['REQUEST_METHOD'] = 'GET';
$this->assertFalse($manager->parseRequest($request));

unset($_SERVER['REQUEST_METHOD']);
}

public function testParseRESTRequest(): void
{
$request = new Request();
Expand Down
Loading