From d8aa1e8768597a5a295dfbcb718521ea4048deed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Bouc=CC=8Cek?= Date: Tue, 13 Apr 2021 20:48:35 +0200 Subject: [PATCH] Init commit --- .gitattributes | 5 + .github/workflows/code_analysis.yaml | 58 +++++++++ .gitignore | 2 + LICENSE | 21 ++++ README.md | 58 +++++++++ composer.json | 37 ++++++ ecs.php | 37 ++++++ src/Escape.php | 103 +++++++++++++++ tests/EscapeTest.php | 180 +++++++++++++++++++++++++++ 9 files changed, 501 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/code_analysis.yaml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 ecs.php create mode 100644 src/Escape.php create mode 100644 tests/EscapeTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bb4b46e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore +ecs.php export-ignore +tests export-ignore diff --git a/.github/workflows/code_analysis.yaml b/.github/workflows/code_analysis.yaml new file mode 100644 index 0000000..e6ef697 --- /dev/null +++ b/.github/workflows/code_analysis.yaml @@ -0,0 +1,58 @@ +name: Code Analysis + +on: + pull_request: + push: + +jobs: + code_analysis: + strategy: + fail-fast: false + matrix: + php: ['7.3', '7.4', '8.0'] + actions: + - name: PHPStan + run: composer phpstan + + - name: Easy Coding Standard + run: composer ecs + + - name: Unit tests + run: vendor/bin/tester tests -s -C + + name: ${{ matrix.actions.name }} at PHP ${{ matrix.php }} + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + + # see https://github.com/shivammathur/setup-php + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: json + coverage: none + + + # see https://github.com/actions/cache/blob/main/examples.md#php---composer + - name: Get Composer Cache Directory + id: composer-cache + run: | + echo "::set-output name=dir::$(composer config cache-files-dir)" + - uses: actions/cache@v2 + with: + path: | + ${{ steps.composer-cache.outputs.dir }} + **/composer.lock + key: ${{ runner.os }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.php }}-composer- + + + - name: Install Composer + run: composer install --no-progress + + - run: ${{ matrix.actions.run }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff72e2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/composer.lock +/vendor diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9251bde --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Jakub Bouček + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f05016 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Escape + +PHP library to right escape outputs in your legacy project. + +Don't use package for new projects, use [Latte](https://latte.nette.org/) instead. + +Package is substrate of [Latte package](https://github.com/nette/latte/) +[filters](https://github.com/nette/latte/blob/master/src/Latte/Runtime/Filters.php). + +## Features + +- Escape HTML +- Escape HTML attributes +- Escape HTML comments +- Escape JS +- Escape CSS +- Escape URL + +## Install + +```shell +composer require redbitcz/escape +``` + +## Usage + +Instead: +```php +echo 'Registered user: ' . $username; +``` + +Use: +```php +echo 'Registered user: ' . \Redbitcz\Escape\Escape::html($username); +``` + +## FAQ + +### Is it support for escaping SQL query? + +No, SQL requires access to active SQL connection to right escape. This package is only aloow to escape contexts without +external requrements. + +## Contributing +Please don't hesitate send Issue or Pull Request. + +## Security +If you discover any security related issues, please email pan@jakubboucek.cz instead of using the issue tracker. + +## License +The MIT License (MIT). Please see [License File](LICENSE) for more information. + +### Origin code licences +- [New BSD License](https://github.com/nette/latte/blob/master/license.md#new-bsd-license) +- [GNU General Public License](https://github.com/nette/latte/blob/master/license.md#gnu-general-public-license) + +Copyright (c) 2004, 2014 David Grudl (https://davidgrudl.com) All rights reserved. +Please see [License File](https://github.com/nette/latte/blob/master/license.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5d4e074 --- /dev/null +++ b/composer.json @@ -0,0 +1,37 @@ +{ + "name": "jakubboucek/legacy-escape", + "description": "Right escape data inserted to HTML, CSS, JS and URL. Substrate of Latte/Latte package.", + "type": "library", + "license": [ + "MIT", + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "Jakub Bouček", + "email": "pan@jakubboucek.cz" + } + ], + "require": { + "php": ">= 7.1", + "nette/utils": "^3.1" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.83", + "symplify/easy-coding-standard": "^9.2", + "nette/tester": "^2.4" + }, + "autoload": { + "psr-4": { + "JakubBoucek\\Escape\\": "src/" + } + }, + "scripts": { + "phpstan": "phpstan analyze src --level 7", + "ecs": "ecs", + "ecs-fix": "ecs --fix", + "tester": "tester tests" + } +} diff --git a/ecs.php b/ecs.php new file mode 100644 index 0000000..122450d --- /dev/null +++ b/ecs.php @@ -0,0 +1,37 @@ +services(); + $services->set(ArraySyntaxFixer::class) + ->call( + 'configure', + [ + [ + 'syntax' => 'short', + ] + ] + ); + + $parameters = $containerConfigurator->parameters(); + $parameters->set( + Option::PATHS, + [ + __DIR__ . '/src', + __DIR__ . '/tests', + ] + ); + + $parameters->set( + Option::SETS, + [ + SetList::PSR_12, + ] + ); +}; diff --git a/src/Escape.php b/src/Escape.php new file mode 100644 index 0000000..a42b781 --- /dev/null +++ b/src/Escape.php @@ -0,0 +1,103 @@ +"\'') === false) { + $data .= ' '; // protection against innerHTML mXSS vulnerability nette/nette#1496 + } + return self::html($data); + } + + /** + * Escapes string for use inside HTML comments. + * @param string|mixed $data + * @return string + * + * @link https://api.nette.org/2.4/source-Latte.Runtime.Filters.php.html#_escapeHtmlComment + */ + public static function htmlComment($data): string + { + $data = (string)$data; + if ($data && ($data[0] === '-' || $data[0] === '>' || $data[0] === '!')) { + $data = ' ' . $data; + } + $data = str_replace('--', '- - ', $data); + if (substr($data, -1) === '-') { + $data .= ' '; + } + return $data; + } + + /** + * Escapes string for use inside JS code + * @param mixed $data + * @return string + * + * @link https://api.nette.org/2.4/source-Latte.Runtime.Filters.php.html#_escapeJs + */ + public static function js($data): string + { + $json = Json::encode($data); + + return str_replace([']]>', '?@[\\]^`{|}~"); + } + + /** + * Escapes string for use inside URL + * @param string|mixed $url + * @return string + */ + public static function url($url): string + { + return urlencode((string)$url); + } +} diff --git a/tests/EscapeTest.php b/tests/EscapeTest.php new file mode 100644 index 0000000..4081c8f --- /dev/null +++ b/tests/EscapeTest.php @@ -0,0 +1,180 @@ +'], + ['< & ' " >', '< & \' " >'], + ['&quot;', '"'], + ['`hello', '`hello'], + ["foo \u{FFFD} bar", "foo \u{D800} bar"], // invalid codepoint high surrogates + ["foo \u{FFFD}" bar", "foo \xE3\x80\x22 bar"], // stripped UTF + ['Hello World', 'Hello World'], + ['Hello <World>', 'Hello '], + ['" ' < > & �', "\" ' < > & \x8F"], + ['`hello`', '`hello`'], + ['` <br> `', '`
`'], + ]; + } + + /** + * @dataProvider getHtmlArgs + */ + public function testHtml(string $expected, $data): void + { + Assert::same($expected, Escape::html($data)); + } + + public function getHtmlAttrArgs(): array + { + return [ + ['', null], + ['', ''], + ['1', 1], + ['string', 'string'], + ['< & ' " >', '< & \' " >'], + ['&quot;', '"'], + ['`hello ', '`hello'], + ['`hello"', '`hello"'], + ['`hello'', "`hello'"], + ["foo \u{FFFD} bar", "foo \u{D800} bar"], // invalid codepoint high surrogates + ["foo \u{FFFD}" bar", "foo \xE3\x80\x22 bar"], // stripped UTF + ['Hello World', 'Hello World'], + ['Hello <World>', 'Hello '], + ['" ' < > & �', "\" ' < > & \x8F"], + ['`hello` ', '`hello`'], + ['``onmouseover=alert(1) ', '``onmouseover=alert(1)'], + ['` <br> `', '`
`'], + ]; + } + + /** + * @dataProvider getHtmlAttrArgs + */ + public function testHtmlAttr(string $expected, $data): void + { + Assert::same($expected, Escape::htmlAttr($data)); + } + + public function getHtmlCommentArgs(): array + { + return [ + ['', null], + ['', ''], + ['1', 1], + ['string', 'string'], + ['< & \' " >', '< & \' " >'], + ['"', '"'], + [' - ', '-'], + [' - - ', '--'], + [' - - - ', '---'], + [' >', '>'], + [' !', '!'], + ["foo \u{D800} bar", "foo \u{D800} bar"], // invalid codepoint high surrogates + ["foo \xE3\x80\x22 bar", "foo \xE3\x80\x22 bar"], // stripped UTF + ['Hello World', 'Hello World'], + ['Hello ', 'Hello '], + ["\" ' < > & \x8F", "\" ' < > & \x8F"], + ['`hello`', '`hello`'], + ['``onmouseover=alert(1)', '``onmouseover=alert(1)'], + ['`
`', '`
`'], + ]; + } + + /** + * @dataProvider getHtmlCommentArgs + */ + public function testHtmlComment(string $expected, $data): void + { + Assert::same($expected, Escape::htmlComment($data)); + } + + public function getJsArgs(): array + { + return [ + ['null', null], + ['""', ''], + ['1', 1], + ['"string"', 'string'], + ['"<\/tag"', ' '0', 'b' => '1']], + ['"<\\/script>"', ''], + ]; + } + + /** + * @dataProvider getJsArgs + */ + public function testJs(string $expected, $data): void + { + Assert::same($expected, Escape::js($data)); + } + + public function getCssArgs(): array + { + return [ + ['', null], + ['', ''], + ['1', 1], + ['string', 'string'], + ['\!\"\#\$\%\&\\\'\(\)\*\+\,\.\/\:\;\<\=\>\?\@\[\\\\\]\^\`\{\|\}\~', '!"#$%&\'()*+,./:;<=>?@[\]^`{|}~'], + ["foo \u{D800} bar", "foo \u{D800} bar"], // invalid codepoint high surrogates + ["foo \xE3\x80\\\x22 bar", "foo \xE3\x80\x22 bar"], // stripped UTF + ['\\<\\/style\\>', ''], + ]; + } + + /** + * @dataProvider getCssArgs + */ + public function testCss(string $expected, $data): void + { + Assert::same($expected, Escape::css($data)); + } + + public function getUrlArgs(): array + { + return [ + ['', null], + ['', ''], + ['1', 1], + ['string', 'string'], + ['a%2Fb', 'a/b'], + ['a%3Fb', 'a?b'], + ['a%26b', 'a&b'], + ['a%2Bb', 'a+b'], + ['a+b', 'a b'], + ['a%27b', 'a\'b'], + ['a%22b', 'a"b'], + ]; + } + + /** + * @dataProvider getUrlArgs + */ + public function testUrl(string $expected, $data): void + { + Assert::same($expected, Escape::url($data)); + } +} + +(new EscapeTest())->run();