Skip to content
This repository was archived by the owner on Jan 30, 2020. It is now read-only.

Add Feature Policy header #177

Merged
merged 9 commits into from
Dec 3, 2019
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ All notable changes to this project will be documented in this file, in reverse
- `trusted-types`,
- `upgrade-insecure-requests`.

- [#177](https://github.com/zendframework/zend-http/pull/177) adds support for Feature Policy header.

### Changed

- Nothing.
Expand Down
2 changes: 1 addition & 1 deletion src/Header/ContentSecurityPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ public function setDirective($name, array $sources)
}

array_walk($sources, [__NAMESPACE__ . '\HeaderValue', 'assertValid']);

$this->directives[$name] = implode(' ', $sources);

return $this;
}

Expand Down
185 changes: 185 additions & 0 deletions src/Header/FeaturePolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<?php
/**
* @see https://github.com/zendframework/zend-http for the canonical source repository
* @copyright Copyright (c) 2019 Zend Technologies USA Inc. (https://www.zend.com)
* @license https://github.com/zendframework/zend-http/blob/master/LICENSE.md New BSD License
*/

namespace Zend\Http\Header;

/**
* Feature Policy (based on Editor’s Draft, 28 November 2019)
*
* @link https://w3c.github.io/webappsec-feature-policy/
*/
class FeaturePolicy implements HeaderInterface
{
/**
* Valid directive names
*
* @var string[]
*
* @see https://github.com/w3c/webappsec-feature-policy/blob/master/features.md
*/
protected $validDirectiveNames = [
// Standardized Features
'accelerometer',
'ambient-light-sensor',
'autoplay',
'battery',
'camera',
'display-capture',
'document-domain',
'fullscreen',
'execution-while-not-rendered',
'execution-while-out-of-viewport',
'gyroscope',
'magnetometer',
'microphone',
'midi',
'payment',
'picture-in-picture',
'picture-in-picture',
'sync-xhr',
'usb',
'wake-lock',
'xr',

// Proposed Features
'encrypted-media',
'geolocation',
'speaker',

// Experimental Features
'document-write',
'font-display-late-swap',
'layout-animations',
'loading-frame-default-eager',
'loading-image-default-eager',
'legacy-image-formats',
'oversized-images',
'sync-script',
'unoptimized-lossy-images',
'unoptimized-lossless-images',
'unsized-media',
'vertical-scroll',
'serial',
];

/**
* The directives defined for this policy
*
* @var array
*/
protected $directives = [];

/**
* Get the list of defined directives
*
* @return array
*/
public function getDirectives()
{
return $this->directives;
}

/**
* Sets the directive to consist of the source list
*
* @param string $name The directive name.
* @param string[] $sources The source list.
* @return $this
* @throws Exception\InvalidArgumentException If the name is not a valid directive name.
*/
public function setDirective($name, array $sources)
{
if (! in_array($name, $this->validDirectiveNames, true)) {
throw new Exception\InvalidArgumentException(sprintf(
'%s expects a valid directive name; received "%s"',
__METHOD__,
(string) $name
));
}
if (empty($sources)) {
$this->directives[$name] = "'none'";
return $this;
}

array_walk($sources, [__NAMESPACE__ . '\HeaderValue', 'assertValid']);

$this->directives[$name] = implode(' ', $sources);
return $this;
}

/**
* Create Feature Policy header from a given header line
*
* @param string $headerLine The header line to parse.
* @return static
* @throws Exception\InvalidArgumentException If the name field in the given header line does not match.
*/
public static function fromString($headerLine)
{
$header = new static();
$headerName = $header->getFieldName();
list($name, $value) = GenericHeader::splitHeaderLine($headerLine);
// Ensure the proper header name
if (strcasecmp($name, $headerName) !== 0) {
throw new Exception\InvalidArgumentException(sprintf(
'Invalid header line for %s string: "%s"',
$headerName,
$name
));
}
// As per https://w3c.github.io/webappsec-feature-policy/#algo-parse-policy-directive
$tokens = explode(';', $value);
foreach ($tokens as $token) {
$token = trim($token);
if ($token) {
list($directiveName, $directiveValue) = array_pad(explode(' ', $token, 2), 2, null);
if (! isset($header->directives[$directiveName])) {
$header->setDirective(
$directiveName,
$directiveValue === null ? [] : [$directiveValue]
);
}
}
}

return $header;
}

/**
* Get the header name
*
* @return string
*/
public function getFieldName()
{
return 'Feature-Policy';
}

/**
* Get the header value
*
* @return string
*/
public function getFieldValue()
{
$directives = [];
foreach ($this->directives as $name => $value) {
$directives[] = sprintf('%s %s;', $name, $value);
}
return implode(' ', $directives);
}

/**
* Return the header as a string
*
* @return string
*/
public function toString()
{
return sprintf('%s: %s', $this->getFieldName(), $this->getFieldValue());
}
}
1 change: 1 addition & 0 deletions src/HeaderLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class HeaderLoader extends PluginClassLoader
'etag' => Header\Etag::class,
'expect' => Header\Expect::class,
'expires' => Header\Expires::class,
'featurepolicy' => Header\FeaturePolicy::class,
'from' => Header\From::class,
'host' => Header\Host::class,
'ifmatch' => Header\IfMatch::class,
Expand Down
115 changes: 115 additions & 0 deletions test/Header/FeaturePolicyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php
/**
* @see https://github.com/zendframework/zend-http for the canonical source repository
* @copyright Copyright (c) 2019 Zend Technologies USA Inc. (https://www.zend.com)
* @license https://github.com/zendframework/zend-http/blob/master/LICENSE.md New BSD License
*/

namespace ZendTest\Http\Header;

use PHPUnit\Framework\TestCase;
use Zend\Http\Header\FeaturePolicy;
use Zend\Http\Header\Exception\InvalidArgumentException;
use Zend\Http\Header\HeaderInterface;

class FeaturePolicyTest extends TestCase
{
public function testFeaturePolicyFromStringThrowsExceptionIfImproperHeaderNameUsed()
{
$this->expectException(InvalidArgumentException::class);
FeaturePolicy::fromString('X-Feature-Policy: geolocation \'none\';');
}

public function testFeaturePolicyFromStringParsesDirectivesCorrectly()
{
$header = FeaturePolicy::fromString(
"Feature-Policy: geolocation 'none'; autoplay 'self'; microphone 'self';"
);
$this->assertInstanceOf(HeaderInterface::class, $header);
$this->assertInstanceOf(FeaturePolicy::class, $header);
$directives = [
'geolocation' => "'none'",
'autoplay' => "'self'",
'microphone' => "'self'",
];
$this->assertEquals($directives, $header->getDirectives());
}

public function testFeaturePolicyGetFieldNameReturnsHeaderName()
{
$header = new FeaturePolicy();
$this->assertEquals('Feature-Policy', $header->getFieldName());
}

public function testFeaturePolicyToStringReturnsHeaderFormattedString()
{
$header = FeaturePolicy::fromString(
"Feature-Policy: geolocation 'none'; autoplay 'self'; microphone 'self';"
);
$this->assertInstanceOf(HeaderInterface::class, $header);
$this->assertInstanceOf(FeaturePolicy::class, $header);
$this->assertEquals(
"Feature-Policy: geolocation 'none'; autoplay 'self'; microphone 'self';",
$header->toString()
);
}

public function testFeaturePolicySetDirective()
{
$fp = new FeaturePolicy();
$fp->setDirective('geolocation', ['https://*.google.com', 'http://foo.com'])
->setDirective('autoplay', ["'self'"])
->setDirective('microphone', ['https://*.googleapis.com', 'https://*.bar.com']);
$header = 'Feature-Policy: geolocation https://*.google.com http://foo.com; '
. 'autoplay \'self\'; microphone https://*.googleapis.com https://*.bar.com;';
$this->assertEquals($header, $fp->toString());
}

public function testFeaturePolicySetDirectiveWithEmptySourcesDefaultsToNone()
{
$header = new FeaturePolicy();
$header->setDirective('geolocation', ["'self'"])
->setDirective('autoplay', ['*'])
->setDirective('microphone', []);
$this->assertEquals(
"Feature-Policy: geolocation 'self'; autoplay *; microphone 'none';",
$header->toString()
);
}

public function testFeaturePolicySetDirectiveThrowsExceptionIfInvalidDirectiveNameGiven()
{
$this->expectException(InvalidArgumentException::class);
$header = new FeaturePolicy();
$header->setDirective('foo', []);
}

public function testFeaturePolicyGetFieldValueReturnsProperValue()
{
$header = new FeaturePolicy();
$header->setDirective('geolocation', ["'self'"])
->setDirective('microphone', ['https://*.github.com']);
$this->assertEquals("geolocation 'self'; microphone https://*.github.com;", $header->getFieldValue());
}

/**
* @see http://en.wikipedia.org/wiki/HTTP_response_splitting
* @group ZF2015-04
*/
public function testPreventsCRLFAttackViaFromString()
{
$this->expectException(InvalidArgumentException::class);
FeaturePolicy::fromString("Feature-Policy: default-src 'none'\r\n\r\nevilContent");
}

/**
* @see http://en.wikipedia.org/wiki/HTTP_response_splitting
* @group ZF2015-04
*/
public function testPreventsCRLFAttackViaDirective()
{
$header = new FeaturePolicy();
$this->expectException(InvalidArgumentException::class);
$header->setDirective('default-src', ["\rsome\r\nCRLF\ninjection"]);
}
}