Yii2: Yii2 does not send Access-Control-Allow-Headers in preflight response

Created on 24 Oct 2018  路  8Comments  路  Source: yiisoft/yii2

What steps will reproduce the problem?

I extend ActiveController and set CORS filter in my application :

'corsFilter' => [
    'class' => \yii\filters\Cors::className(),
    'cors' => [
        'Origin' => ["*"],
        'Access-Control-Allow-Headers' => ["Application-Key"]
    ]
],

I also added to the authenticator:

'except' => ["options"]

What is the expected result?

I created test client application:

$.ajax({
    method: 'GET',
    url: "https://my-app-url.com/entity/1",
    headers: {
        "Application-Key":"123456someappkey"
    }
}).done(function(data) { 
    console.log(data);
});

I expected to receive data from application

What do you get instead?

I got next error:
Access to XMLHttpRequest at 'https://my-app-url.com/entity/1' from origin 'https://fiddle.jshell.net' has been blocked by CORS policy: Request header field Application-Key is not allowed by Access-Control-Allow-Headers in preflight response.

Additional info

| Q | A
| ---------------- | ---
| Yii version | 2.0.15.1
| PHP version | 7.2
| Operating system | Debian GNU/Linux 9

bug

Most helpful comment

Same issue with ES6 fetch request.

All 8 comments

Is there any progress about this? I have the very same problem and noticed the only way I can extend the allowed request headers is by creating a custom filter like this

class Cors extends \yii\filters\Cors
{
    /**
     * Extract CORS headers from the request.
     * @return array CORS headers to handle
     */
    public function extractHeaders()
    {
        $headers = [];
        $_SERVER['HTTP_ACCESS_CONTROL_ALLOW_HEADERS'] = 'MY_CUSTOM_HEADER, X-CSRF-Token, Origin, X-Requested-With, Content-Type, Accept';
        $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] = 'MY_CUSTOM_HEADER, X-CSRF-Token, Origin, X-Requested-With, Content-Type, Accept';

        foreach (array_keys($this->cors) as $headerField) {
            $serverField = $this->headerizeToPhp($headerField);
            $headerData = $_SERVER[$serverField] ?? null;
            if ($headerData !== null) {
                $headers[$headerField] = $headerData;
            }
        }

        return $headers;
    }
}

The extractHeaders defines the allowed headers, if your custom key is not within those you don't really have a chance to include your own. Is that intended? So the property Access-Control-Allow-Headers only allows you to limit the allowed headers you are not allowed to extend them

Same issue with ES6 fetch request.

@blacksmoke26 any easy way to reproduce it?

I have fixed it already, please let me share the code:

../config/main.php file

use api\components\handlers\{
  RequestHandler,
  ResponseHandler,
};
use yii\web\Response;

$config = [
  //....
  'components' => [
    'response' => [
      'class' => Response::class,
      'format' => Response::FORMAT_JSON,
      'on beforeSend' => function ($event) {
        (new ResponseHandler)->beforeSend ($event->sender);
      },
      'on afterSend' => function ($event) {
        (new ResponseHandler)->afterSend($event->sender);
      },
    ],
  ],
  'on beforeRequest' => function () {
    (new RequestHandler)->beforeSend();
  },
  'on afterRequest' => function () {
    (new RequestHandler)->afterSend();
  },
  //....
];

api\components\handlers\RequestHandler.php file

namespace api\components\handlers;

use Yii;

/**
 * Class RequestHandler
 * @package api\components\response
 */
class RequestHandler {
 /**
  * Execute event before response send
  */
  public function beforeSend () : void {
    /** @var string $pathInfo */
    $pathInfo = Yii::$app->request->pathInfo;

    // fixed: UrlRule Problem with trailing slash (/)
    if ( !empty($pathInfo) && '/' === \substr ($pathInfo, -1) ) {
      Yii::$app->response->redirect('/' . \rtrim($pathInfo, '/'))->send();
      exit;
    }

    // No output for OPTIONS
    if ( 'OPTIONS' === Yii::$app->getRequest ()->getMethod () ) {
      Yii::$app->end (); 
      exit;
    }
  }

  /**
   * Execute event after request sent
   */
  public function afterSend () : void {}
}

api\components\handlers\ResponseHandler.php file

namespace api\components\handlers;

use common\utils\Configuration;
use Yii;
use yii\web\Response;

/**
 * Class ResponseHandler
 * @package api\components\response
 */
class ResponseHandler {
  /**
   * Execute event before response send
   * @param \yii\web\Response $response Response object
   */
  public function beforeSend ( Response $response ) : void {
    // CORS headers
    $this->setupCorsHeaders ($response);

    // Format output response
    $this->responseFormatter ($response);
  }

  /**
   * Execute event after response sent
   * @param \yii\web\Response $response Response object
   */
  public function afterSend ( Response $response ) : void {}

  /**
   * Format output response
   * @param \yii\web\Response $response Response object
   */
  private function responseFormatter ( Response $response ) : void {
    /** @var array $data */
    $data = $response->data;

    if ( !$response->isSuccessful ) {
      if ( !$response->statusCode ) {
        $response->statusCode = 400;
      }

      if ( \is_countable ($data) && \count ($data) ) {
        $response->data = [];
        unset($data['previous']);
        $response->data = $data;
      }
      return;
    }

    if ( \null !== $data ) {
      if ( \is_countable($data) && \count ($data) ) {
        $response->data = [];
        $response->data['data'] = $data;
      }
    }

    if ( !$response->statusCode ) {
      $response->statusCode = 200;
    }
  }

  /**
   * Setup CORS headers
   * @param \yii\web\Response $response Response object
   */
  private function setupCorsHeaders ( Response $response ) : void {
    /** @var \yii\web\HeaderCollection $headers */
    $headers = $response->headers;

    /** @var array $allowedHeaders */
    $allowedHeaders = Configuration::current ('security.cors.allowed.headers', ['toArray'=>\true]);

    $headers->set ('Access-Control-Allow-Origin', static::getOrigin ());
    $headers->set ('Access-Control-Allow-Credentials', 'true');
    $headers->set ('Access-Control-Max-Age', (int) Configuration::current ('security.cors.maxAge'));
    $headers->set ('Access-Control-Allow-Headers', \implode (', ', $allowedHeaders));
    $headers->set ('Access-Control-Allow-Methods', 'GET, OPTIONS, POST, DELETE, PUT, HEAD, PATCH');
  }

  /**
   * Get origin from request
   * @return string
   */
  private static function getOrigin () : string {
    /** @var \yii\web\HeaderCollection $headers */
    $headers = Yii::$app->request->headers;

    /** @var string[] $whitelistOrigins */
    $whitelistOrigins = Configuration::current ('security.cors.allowed.origins', ['toArray'=>\true]);

    /** @var string $origin */
    $origin = $headers->get ('origin')
      ?? $headers->get ('host')
      ?? \null;

    foreach ( $whitelistOrigins as $host ) {
      if ( \false !== \strpos ($origin, $host) ) {
        return $origin;
      }
    }

    return Configuration::current ('uri.baseUrl');
  }
}

At end, main controller file /components/Controller.php

namespace api\components;

use common\utils\Configuration;
use Yii;

/**
 * Class Controller
 * @package api\components
 */
class Controller extends \yii\web\Controller {
  /**
   * @inheritdoc
   */
  public function init() {
    parent::init();

    // Disabled CSRF validation
    $this->enableCsrfValidation = \false;

    // Disabled layouts
    $this->layout = \false;
  }

  /**
   * @inheritDoc
   */
  public function behaviors () {
    return [
      'hostControl' => [
        'class' => \yii\filters\HostControl::class,
        'allowedHosts' => [
          '*.' . Configuration::current ('uri.baseDomain'),
        ],
      ],
      'authenticator' => [
        'class' => \common\components\jwt\filters\AutoAuth::class,
        'except' => ['OPTIONS'],
      ],
      'access' => [
        'class' => \yii\filters\AccessControl::class,
        'except' => ['OPTIONS'],
      ],
      'verbs' => [
        'class' => \yii\filters\VerbFilter::class,
        'actions' => [
          '*' => ['OPTIONS'],
        ],
      ],
    ];
  }
}

The method Configuration::current() uses following JSON to return values:

{
  "uri": {
    "baseDomain": "domain.test"
  },
  "security": {
    "cors": {
      "allowed": {
        "origins": ["domain.test"],
        "headers": [
          "Accept", "Origin", "X-Auth-Token",
          "Content-Type", "Authorization", "X-Requested-With",
          "Accept-Language", "Last-Event-ID", "Accept-Language",
          "Cookie", "Content-Length", "WWW-Authenticate", "X-XSRF-TOKEN",
          "withcredentials", "x-forwarded-for", "x-real-ip",
          "x-customheader", "user-agent", "keep-alive", "host",
          "connection", "upgrade", "dnt", "if-modified-since", "cache-control"
        ]
      },
      "maxAge": 86400
    }
  }
}

Above is my code to handle CORS, ES6 fetch and jQuery $.ajax both are comfortable with that.

@blacksmoke26, you just set necessary headers manually. I did roughly the same thing in my case but I'm not sure it is a good solution. It should be fixed in the framework core or at least clarified in documentation.

@Qclanton, nope, it's not! but was in hurry! So did that on purpose.

My solution was simply adding the following line at the top of the index.php, and it works though it seems anti-pattern:

header('Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Authorization');

I solved adding this line before of return $responseHeaders; on function prepareHeaders($requestHeaders) in vendor/yiisoft/yii2/filters/Cors.php:

$responseHeaders['Access-Control-Allow-Headers'] = $this->cors['Access-Control-Allow-Headers'];

But I did not like to edit this in a vendor dependency, so...

As I'm using advanced Yii2 application, I created the class file common/filters/Cors.php like this:

<?php
namespace common\filters;

class Cors extends \yii\filters\Cors {
    public function prepareHeaders($requestHeaders) {
        $responseHeaders = parent::prepareHeaders($requestHeaders);
        if (isset($this->cors['Access-Control-Allow-Headers'])) {
            $responseHeaders['Access-Control-Allow-Headers'] = implode(', ', $this->cors['Access-Control-Allow-Headers']);
        }
        return $responseHeaders;
    }
}

In my controller, I did put this:

public static function allowedDomains() {
    return [$_SERVER["REMOTE_ADDR"], 'http://localhost:4200'];
}

function behaviors()
    {
        $behaviors = parent::behaviors();
        $behaviors['contentNegotiator'] = [
            'class' => ContentNegotiator::className(),
            'formats' => [
                'application/json' => Response::FORMAT_JSON,
            ],
        ];
        return array_merge($behaviors, [
            'corsFilter'  => [
                'class' => \common\filters\Cors::className(),
                'cors'  => [
                    // restrict access to domains:
                    'Origin'                           => static::allowedDomains(),
                    'Access-Control-Request-Method'    => ['POST', 'GET', 'OPTIONS'],
                    'Access-Control-Allow-Credentials' => true,
                    'Access-Control-Max-Age'           => 3600,                 // Cache (seconds)
                    'Access-Control-Allow-Headers' => ['authorization','X-Requested-With','content-type', 'some_custom_header']
                ],
            ],
        ]);
        return $behaviors;
    }
Was this page helpful?
0 / 5 - 0 ratings