From afd4a839db91cf9dbc6b9e5ab44b46aec030c98a Mon Sep 17 00:00:00 2001 From: Dominik Klapuch Date: Mon, 8 May 2017 14:55:39 +0200 Subject: [PATCH] Init --- .gitignore | 2 + .travis.yml | 27 ++++++++ Core/CombinedExtension.php | 16 +++++ Core/CspHeader.php | 43 ++++++++++++ Core/Extension.php | 11 ++++ Core/HeaderExtension.php | 23 +++++++ Core/IniSetExtension.php | 16 +++++ Core/InternationalExtension.php | 20 ++++++ Core/RawHeaderExtension.php | 16 +++++ Core/SessionExtension.php | 74 +++++++++++++++++++++ README.md | 3 + Tests/.coveralls.yml | 3 + Tests/.gitignore | 2 + Tests/Temporary/.gitginore | 2 + Tests/Unit/CombinedExtension.phpt | 40 ++++++++++++ Tests/Unit/CspHeader.phpt | 52 +++++++++++++++ Tests/Unit/HeaderExtension.phpt | 24 +++++++ Tests/Unit/IniSetExtension.phpt | 24 +++++++ Tests/Unit/InternationalExtension.phpt | 31 +++++++++ Tests/Unit/RawHeaderExtension.phpt | 46 +++++++++++++ Tests/Unit/SessionExtension.phpt | 87 +++++++++++++++++++++++++ Tests/bootstrap.php | 5 ++ Tests/php.ini | 1 + appveyor.yml | 31 +++++++++ build.xml | 90 ++++++++++++++++++++++++++ composer.json | 40 ++++++++++++ phpstan.neon | 3 + ruleset.xml | 66 +++++++++++++++++++ 28 files changed, 798 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 Core/CombinedExtension.php create mode 100644 Core/CspHeader.php create mode 100644 Core/Extension.php create mode 100644 Core/HeaderExtension.php create mode 100644 Core/IniSetExtension.php create mode 100644 Core/InternationalExtension.php create mode 100644 Core/RawHeaderExtension.php create mode 100644 Core/SessionExtension.php create mode 100644 README.md create mode 100644 Tests/.coveralls.yml create mode 100644 Tests/.gitignore create mode 100644 Tests/Temporary/.gitginore create mode 100644 Tests/Unit/CombinedExtension.phpt create mode 100644 Tests/Unit/CspHeader.phpt create mode 100644 Tests/Unit/HeaderExtension.phpt create mode 100644 Tests/Unit/IniSetExtension.phpt create mode 100644 Tests/Unit/InternationalExtension.phpt create mode 100644 Tests/Unit/RawHeaderExtension.phpt create mode 100644 Tests/Unit/SessionExtension.phpt create mode 100644 Tests/bootstrap.php create mode 100644 Tests/php.ini create mode 100644 appveyor.yml create mode 100644 build.xml create mode 100644 composer.json create mode 100644 phpstan.neon create mode 100644 ruleset.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..d30fd8e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,27 @@ +sudo: required + +language: php + +php: + - 7.1 + +before_install: + - composer self-update + +install: + - composer install --no-interaction --prefer-dist --no-scripts --no-progress --no-suggest --optimize-autoloader --classmap-authoritative + +script: + - ./vendor/bin/phing ci + +after_script: + - > + wget https://github.com/satooshi/php-coveralls/releases/download/v1.0.1/coveralls.phar + && php coveralls.phar --verbose --config Tests/.coveralls.yml; + +after_failure: + - for i in $(find Tests -name \*.actual); do echo "--- $i"; cat $i; echo; echo; done + +cache: + directories: + - $HOME/.composer/cache diff --git a/Core/CombinedExtension.php b/Core/CombinedExtension.php new file mode 100644 index 0000000..fefb063 --- /dev/null +++ b/Core/CombinedExtension.php @@ -0,0 +1,16 @@ +extensions = $extensions; + } + + public function improve(): void { + foreach ($this->extensions as $extension) + $extension->improve(); + } +} \ No newline at end of file diff --git a/Core/CspHeader.php b/Core/CspHeader.php new file mode 100644 index 0000000..868a118 --- /dev/null +++ b/Core/CspHeader.php @@ -0,0 +1,43 @@ +directives = $directives; + } + + public function nonce(): string { + if ($this->nonce === null) + $this->nonce = base64_encode(random_bytes(16)); + return $this->nonce; + } + + public function __toString(): string { + if (!$this->directives) + return ''; + return 'Content-Security-Policy: ' . $this->withNonce( + $this->format($this->directives) + ); + } + + private function format(array $directives): string { + return implode( + '; ', + array_map( + function(string $directive, string $constraint): string { + return $directive . ' ' . $constraint; + }, + array_keys($directives), + $directives + ) + ); + } + + private function withNonce(string $header): string { + return str_replace('nonce', 'nonce-' . $this->nonce(), $header); + } +} \ No newline at end of file diff --git a/Core/Extension.php b/Core/Extension.php new file mode 100644 index 0000000..9baefb7 --- /dev/null +++ b/Core/Extension.php @@ -0,0 +1,11 @@ +headers = $headers; + } + + public function improve(): void { + (new RawHeaderExtension( + array_map( + function(string $field, string $value): string { + return sprintf('%s:%s', $field, $value); + }, + array_keys($this->headers), + $this->headers + ) + ))->improve(); + } +} \ No newline at end of file diff --git a/Core/IniSetExtension.php b/Core/IniSetExtension.php new file mode 100644 index 0000000..3eddb99 --- /dev/null +++ b/Core/IniSetExtension.php @@ -0,0 +1,16 @@ +settings = $settings; + } + + public function improve(): void { + foreach ($this->settings as $name => $value) + ini_set($name, (string) $value); + } +} \ No newline at end of file diff --git a/Core/InternationalExtension.php b/Core/InternationalExtension.php new file mode 100644 index 0000000..4fedd49 --- /dev/null +++ b/Core/InternationalExtension.php @@ -0,0 +1,20 @@ +timezone = $timezone; + } + + public function improve(): void { + mb_internal_encoding('UTF-8'); + if (@date_default_timezone_set($this->timezone) === false) { + throw new \InvalidArgumentException( + sprintf('Timezone "%s" is invalid', $this->timezone) + ); + } + } +} \ No newline at end of file diff --git a/Core/RawHeaderExtension.php b/Core/RawHeaderExtension.php new file mode 100644 index 0000000..7f43a99 --- /dev/null +++ b/Core/RawHeaderExtension.php @@ -0,0 +1,16 @@ +headers = $headers; + } + + public function improve(): void { + foreach ($this->headers as $header) + header((string) $header); + } +} \ No newline at end of file diff --git a/Core/SessionExtension.php b/Core/SessionExtension.php new file mode 100644 index 0000000..e7a1e54 --- /dev/null +++ b/Core/SessionExtension.php @@ -0,0 +1,74 @@ +settings = $settings; + $this->break = $break; + } + + public function improve(): void { + if (session_status() === PHP_SESSION_NONE) + session_start($this->native($this->settings)); + if ($this->elapsed($this->break)) + session_regenerate_id(true); + header(static::raw($this->settings)); + $_SESSION[self::TIMER] = time(); + } + + private function elapsed(int $break): bool { + return isset($_SESSION[self::TIMER]) + && (time() - $_SESSION[self::TIMER]) > $break; + } + + /** + * Just the native and supported php setting + * @param array $settings + * @return array + */ + private function native(array $settings): array { + return array_diff_ukey( + $settings, + array_flip(self::PROPRIETARIES), + 'strcasecmp' + ); + } + + /** + * The raw cookie header + * @param array $settings + * @return string + */ + private static function raw(array $settings): string { + $matches = array_intersect_ukey( + array_flip(self::PROPRIETARIES), + $settings, + 'strcasecmp' + ); + $headers = array_combine( + array_flip($matches), + array_intersect_ukey($settings, $matches, 'strcasecmp') + ); + return sprintf( + '%s; %s', + current(preg_grep('~^Set-Cookie: ~', headers_list())), + implode( + ';', + array_map( + function(string $field, string $value): string { + return sprintf('%s=%s', $field, $value); + }, + array_keys($headers), + $headers + ) + ) + ); + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f81884c --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Internal +[![Build Status](https://travis-ci.org/klapuch/Internal.svg?branch=master)](https://travis-ci.org/klapuch/Internal) [![Build status](https://ci.appveyor.com/api/projects/status/80sjyunbbtmdneyp?svg=true)](https://ci.appveyor.com/project/facedown/project) [![Coverage Status](https://coveralls.io/repos/github/klapuch/Internal/badge.svg?branch=master)](https://coveralls.io/github/klapuch/Internal?branch=master) + diff --git a/Tests/.coveralls.yml b/Tests/.coveralls.yml new file mode 100644 index 0000000..c512a3d --- /dev/null +++ b/Tests/.coveralls.yml @@ -0,0 +1,3 @@ +service_name: travis-ci +coverage_clover: coverage.xml +json_path: coverage.json diff --git a/Tests/.gitignore b/Tests/.gitignore new file mode 100644 index 0000000..45cff74 --- /dev/null +++ b/Tests/.gitignore @@ -0,0 +1,2 @@ +*.actual +*.expected diff --git a/Tests/Temporary/.gitginore b/Tests/Temporary/.gitginore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/Tests/Temporary/.gitginore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/Tests/Unit/CombinedExtension.phpt b/Tests/Unit/CombinedExtension.phpt new file mode 100644 index 0000000..4914eef --- /dev/null +++ b/Tests/Unit/CombinedExtension.phpt @@ -0,0 +1,40 @@ + 7.1 + */ +namespace Klapuch\Internal\Unit; + +use Klapuch\Internal; +use Tester; +use Tester\Assert; + +require __DIR__ . '/../bootstrap.php'; + +final class CombinedExtension extends Tester\TestCase { + public function testConvertingIntegerToString() { + ob_start(); + (new Internal\CombinedExtension( + new class implements Internal\Extension { + function improve(): void { + echo 'a'; + } + }, + new class implements Internal\Extension { + function improve(): void { + echo 'b'; + } + }, + new class implements Internal\Extension { + function improve(): void { + echo 'c'; + } + } + ))->improve(); + Assert::same('abc', ob_get_clean()); + } +} + + +(new CombinedExtension())->run(); \ No newline at end of file diff --git a/Tests/Unit/CspHeader.phpt b/Tests/Unit/CspHeader.phpt new file mode 100644 index 0000000..8d77b47 --- /dev/null +++ b/Tests/Unit/CspHeader.phpt @@ -0,0 +1,52 @@ + 7.1 + */ +namespace Klapuch\Internal\Unit; + +use Klapuch\Internal; +use Tester; +use Tester\Assert; + +require __DIR__ . '/../bootstrap.php'; + +final class CspHeader extends Tester\TestCase { + public function testNoHeaderOnNoPassedDirectives() { + Assert::same('', (string) new Internal\CspHeader([])); + } + + /** + * @dataProvider directives + */ + public function testFormattedDirectives(string $header, array $directives) { + Assert::same( + 'Content-Security-Policy: ' . $header, + (string) new Internal\CspHeader($directives) + ); + } + + protected function directives(): array { + // expected => given + return [ + ["default-src 'self'", ['default-src' => "'self'"]], + ["script-src 'self' 'unsafe-inline'", ['script-src' => "'self' 'unsafe-inline'"]], + ["default-src 'self'; child-src 'none'", ['default-src' => "'self'", 'child-src' => "'none'"]], + ]; + } + + public function testGeneratingNonceJustOnce() { + $csp = new Internal\CspHeader([]); + Assert::true(strlen($csp->nonce()) === 24); + Assert::same($csp->nonce(), $csp->nonce()); + } + + public function testPassingNonce() { + $csp = new Internal\CspHeader(['sctipt-src' => 'nonce']); + Assert::contains('nonce-' . $csp->nonce(), (string) $csp); + } +} + + +(new CspHeader())->run(); \ No newline at end of file diff --git a/Tests/Unit/HeaderExtension.phpt b/Tests/Unit/HeaderExtension.phpt new file mode 100644 index 0000000..47f93dd --- /dev/null +++ b/Tests/Unit/HeaderExtension.phpt @@ -0,0 +1,24 @@ + 7.1 + */ +namespace Klapuch\Internal\Unit; + +use Klapuch\Internal; +use Tester; +use Tester\Assert; + +require __DIR__ . '/../bootstrap.php'; + +final class HeaderExtension extends Tester\TestCase { + public function testSentHeaders() { + (new Internal\HeaderExtension(['Name' => 'Value', 'Foo' => 'bar']))->improve(); + Assert::contains('Name:Value', headers_list()); + Assert::contains('Foo:bar', headers_list()); + } +} + + +(new HeaderExtension())->run(); \ No newline at end of file diff --git a/Tests/Unit/IniSetExtension.phpt b/Tests/Unit/IniSetExtension.phpt new file mode 100644 index 0000000..33704e6 --- /dev/null +++ b/Tests/Unit/IniSetExtension.phpt @@ -0,0 +1,24 @@ + 7.1 + */ +namespace Klapuch\Internal\Unit; + +use Klapuch\Internal; +use Tester; +use Tester\Assert; + +require __DIR__ . '/../bootstrap.php'; + +final class IniSetExtension extends Tester\TestCase { + public function testConvertingIntegerToString() { + Assert::same('1', ini_get('display_errors')); + (new Internal\IniSetExtension(['display_errors' => 0]))->improve(); + Assert::same('0', ini_get('display_errors')); + } +} + + +(new IniSetExtension())->run(); \ No newline at end of file diff --git a/Tests/Unit/InternationalExtension.phpt b/Tests/Unit/InternationalExtension.phpt new file mode 100644 index 0000000..f782039 --- /dev/null +++ b/Tests/Unit/InternationalExtension.phpt @@ -0,0 +1,31 @@ + 7.1 + */ +namespace Klapuch\Internal\Unit; + +use Klapuch\Internal; +use Tester; +use Tester\Assert; + +require __DIR__ . '/../bootstrap.php'; + +final class InternationalExtension extends Tester\TestCase { + public function testSettingAllowedTimezone() { + Assert::same('Europe/Prague', date_default_timezone_get()); + (new Internal\InternationalExtension('Europe/Berlin'))->improve(); + Assert::same('Europe/Berlin', date_default_timezone_get()); + } + + /** + * @throws \InvalidArgumentException Timezone "Foo" is invalid + */ + public function testThrowingOnUnknownTimezone() { + (new Internal\InternationalExtension('Foo'))->improve(); + } +} + + +(new InternationalExtension())->run(); \ No newline at end of file diff --git a/Tests/Unit/RawHeaderExtension.phpt b/Tests/Unit/RawHeaderExtension.phpt new file mode 100644 index 0000000..5ec3e33 --- /dev/null +++ b/Tests/Unit/RawHeaderExtension.phpt @@ -0,0 +1,46 @@ + 7.1 + */ +namespace Klapuch\Internal\Unit; + +use Klapuch\Internal; +use Tester; +use Tester\Assert; + +require __DIR__ . '/../bootstrap.php'; + +final class RawHeaderExtension extends Tester\TestCase { + public function testSentHeaders() { + (new Internal\RawHeaderExtension( + ['Name:Value', 'Foo:bar'] + ))->improve(); + Assert::contains('Name:Value', headers_list()); + Assert::contains('Foo:bar', headers_list()); + } + + public function testStringConversion() { + (new Internal\RawHeaderExtension( + [new class { + function __toString() { + return 'Name:Value'; + } + }] + ))->improve(); + Assert::contains('Name:Value', headers_list()); + } + + public function testRemovingCaseInsensitiveDuplicates() { + (new Internal\RawHeaderExtension( + ['Name:Value', 'Foo:bar', 'name:Value'] + ))->improve(); + Assert::notContains('Name:Value', headers_list()); + Assert::contains('name:Value', headers_list()); + Assert::contains('Foo:bar', headers_list()); + } +} + + +(new RawHeaderExtension())->run(); \ No newline at end of file diff --git a/Tests/Unit/SessionExtension.phpt b/Tests/Unit/SessionExtension.phpt new file mode 100644 index 0000000..929f6a2 --- /dev/null +++ b/Tests/Unit/SessionExtension.phpt @@ -0,0 +1,87 @@ + 7.1 + */ +namespace Klapuch\Internal\Unit; + +use Klapuch\Internal; +use Tester; +use Tester\Assert; + +require __DIR__ . '/../bootstrap.php'; + +final class SessionExtension extends Tester\TestCase { + public function testRegeneratingSessionAfterElapse() { + $extension = new Internal\SessionExtension([], 1); + $extension->improve(); + $initId = session_id(); + $_SESSION['_timer'] = time() - 2; + $extension->improve(); + Assert::notSame('', $initId); + Assert::notSame($initId, session_id()); + } + + public function testInstantRegeneration() { + $extension = new Internal\SessionExtension([], 0); + $extension->improve(); + $initId = session_id(); + $_SESSION['_timer'] = time() - 1; + $extension->improve(); + Assert::notSame($initId, session_id()); + } + + public function testElapseWithOverwhelmingElapse() { + $extension = new Internal\SessionExtension([], 1); + $extension->improve(); + $initId = session_id(); + $_SESSION['_timer'] = time() - 1; + $extension->improve(); + Assert::same($initId, session_id()); + } + + public function testNoPassedSetting() { + $extension = new Internal\SessionExtension([]); + $extension->improve(); + Assert::match('~^Set-Cookie: PHPSESSID=\S+; path=/;$~', headers_list()[4]); + } + + public function testPassingSetting() { + $extension = new Internal\SessionExtension(['cookie_httponly' => true]); + $extension->improve(); + Assert::contains('HttpOnly', headers_list()[4]); + Assert::true(session_get_cookie_params()['httponly']); + } + + public function testSettingSameSite() { + $extension = new Internal\SessionExtension(['SameSite' => 'strict']); + $extension->improve(); + $cookie = headers_list()[4]; + Assert::match( + '~^Set-Cookie: PHPSESSID=\S+; path=/; SameSite=strict$~', + $cookie + ); + } + + public function testSettingSameSiteAsCaseInsensitive() { + $extension = new Internal\SessionExtension(['sAmESiTe' => 'strict']); + $extension->improve(); + $cookie = headers_list()[4]; + Assert::contains('Set-Cookie: ', $cookie); + Assert::contains('; SameSite=strict', $cookie); + } + + public function testProprietarSettingAfterRegeneration() { + $extension = new Internal\SessionExtension(['SameSite' => 'strict'], 1); + $extension->improve(); + $_SESSION['_timer'] = time() - 2; + $extension->improve(); + $cookie = headers_list()[4]; + Assert::contains('Set-Cookie: ', $cookie); + Assert::contains('; SameSite=strict', $cookie); + } +} + + +(new SessionExtension())->run(); \ No newline at end of file diff --git a/Tests/bootstrap.php b/Tests/bootstrap.php new file mode 100644 index 0000000..dbdec0a --- /dev/null +++ b/Tests/bootstrap.php @@ -0,0 +1,5 @@ + appveyor.yml + - '%LOCALAPPDATA%\Composer\files -> appveyor.yml' + +clone_folder: c:\projects\project + +init: + - SET PATH=c:\php\71;%PATH% + - SET ANSICON=121x90 (121x90) + +install: + - IF EXIST c:\php\71 (SET PHP=0) ELSE (SET PHP=1) + - IF %PHP%==1 mkdir c:\php\71 + - IF %PHP%==1 cd c:\php\71 + - IF %PHP%==1 appveyor DownloadFile http://windows.php.net/downloads/releases/archives/php-7.1.0-Win32-VC14-x64.zip + - IF %PHP%==1 7z x php-7.1.0-Win32-VC14-x64.zip >nul + - IF %PHP%==1 echo extension_dir=ext >> php.ini + - IF %PHP%==1 echo extension=php_openssl.dll >> php.ini + - IF %PHP%==1 echo extension=php_mbstring.dll >> php.ini + - IF %PHP%==1 echo extension=php_xsl.dll >> php.ini + - IF %PHP%==1 del /Q *.zip + + - cd c:\projects\project + - echo extension_dir=ext >> c:\php\php.ini + + - appveyor DownloadFile https://getcomposer.org/composer.phar + - php composer.phar install --no-interaction --prefer-dist --no-scripts --no-progress --no-suggest --optimize-autoloader --classmap-authoritative + +test_script: + - vendor/bin/tester -o console -s -c c:\php\php.ini -p c:\php\71\php-cgi Tests diff --git a/build.xml b/build.xml new file mode 100644 index 0000000..fd6e83b --- /dev/null +++ b/build.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3896992 --- /dev/null +++ b/composer.json @@ -0,0 +1,40 @@ +{ + "name": "klapuch/internal", + "description": "PHP improved internals", + "type": "project", + "homepage": "https://www.github.com/klapuch/Internal", + "authors": [ + { + "name": "Dominik Klapuch", + "email": "klapuchdominik@gmail.com", + "homepage": "https://www.github.com/klapuch", + "role": "Developer" + } + ], + "require": { + "php": ">=7.1" + }, + "require-dev": { + "nette/tester": "^1.7", + "phpstan/phpstan": ">=0.6.0", + "jakub-onderka/php-parallel-lint": "^0.9.2", + "slevomat/coding-standard": "^2.0.0", + "sebastian/phpcpd": "^2.0", + "phing/phing": "^2.16", + "consistence/coding-standard": "^0.13.0" + }, + "autoload": { + "psr-4": { + "Klapuch\\Internal\\": [ + "Core/" + ] + } + }, + "autoload-dev": { + "psr-4": { + "Klapuch\\Internal\\": [ + "Tests/" + ] + } + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..0d1eb8e --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,3 @@ +parameters: + ignoreErrors: + - '~Function session_start invoked with 1 parameter, 0 required~' \ No newline at end of file diff --git a/ruleset.xml b/ruleset.xml new file mode 100644 index 0000000..c0bf78f --- /dev/null +++ b/ruleset.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + Tests/Unit + Tests/Integration + Tests/UI + + + + + + + + + + + + + + + + Tests/Unit + Tests/Integration + Tests/UI + + + + + + + + + + + + + + + + + + + + + + + + + + + +