diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh index d8f50136..a59fbfbf 100755 --- a/.devcontainer/postCreateCommand.sh +++ b/.devcontainer/postCreateCommand.sh @@ -1,9 +1,16 @@ #!/bin/sh export PATH="$PATH:$HOME/.composer/vendor/bin" +export PATH="$PATH:$PWD/vendor/bin" +echo 'export PATH="$PATH:$HOME/.composer/vendor/bin"' >> ~/.bashrc +echo 'export PATH="$PATH:$PWD/vendor/bin"' >> ~/.bashrc ln -s ./conf.sample.php cfg/conf.php composer install --no-dev --optimize-autoloader +# for PHP unit testing +# composer require google/cloud-storage +# composer install --optimize-autoloader + sudo chmod a+x "$(pwd)" && sudo rm -rf /var/www/html && sudo ln -s "$(pwd)" /var/www/html npm install --global nyc diff --git a/.github/workflows/snyk-scan.yml b/.github/workflows/snyk-scan.yml index 72e58de3..851211f2 100644 --- a/.github/workflows/snyk-scan.yml +++ b/.github/workflows/snyk-scan.yml @@ -12,7 +12,12 @@ jobs: # https://github.com/snyk/actions/tree/master/php snyk-php: runs-on: ubuntu-latest - if: ${{ github.repository == 'PrivateBin/PrivateBin' }} + if: | + github.repository == 'PrivateBin/PrivateBin' && ( + github.event.pull_request.author_association == 'COLLABORATOR' || + github.event.pull_request.author_association == 'CONTRIBUTOR' || + github.event.pull_request.author_association == 'MEMBER' || + github.event.pull_request.author_association == 'OWNER' ) steps: - uses: actions/checkout@v5 - name: Install Google Cloud Storage diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 01cfdbb4..a33660b2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,8 @@ name: Tests on: push: + pull_request: + branches: [ master ] workflow_dispatch: jobs: diff --git a/.gitignore b/.gitignore index 07ce26ed..09ca72e8 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ vendor/**/tst vendor/**/tests vendor/**/build_phar.php !vendor/**/*.php +vendor/bin/** # Ignore local node modules, unit testing logs, api docs and IDE project files js/node_modules/ diff --git a/i18n/fr.json b/i18n/fr.json index 180cb00e..d5715d4b 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -224,7 +224,7 @@ "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Erreur lors de la décompression du document, votre navigateur ne supporte pas WebAssembly. Veuillez utiliser un autre navigateur pour voir ce document.", "Start over": "Recommencer", "Document copied to clipboard": "Document copié dans le presse-papier", - "To copy document press on the copy button or use the clipboard shortcut Ctrl+c/Cmd+c": "Pour copier-coller appuyer sur le bouton To copy document press on the copy button or use the clipboard shortcut Ctrl+c/Cmd+c", + "To copy document press on the copy button or use the clipboard shortcut Ctrl+c/Cmd+c": "Pour copier appuyer sur le bouton Copier le lien ou utiliser le raccourci Ctrl+c/Cmd+c", "Copy link": "Copier le lien", "Link copied to clipboard": "Lien copié dans le presse-papier", "Document text": "Texte du document", diff --git a/js/legacy.js b/js/legacy.js index 5d6dbc0b..1f27107b 100644 --- a/js/legacy.js +++ b/js/legacy.js @@ -106,8 +106,8 @@ return window.isSecureContext; } - // HTTP is obviously insecure - if (window.location.protocol !== 'http:') { + // HTTPS is considered secure + if (window.location.protocol === 'https:') { return true; } diff --git a/js/privatebin.js b/js/privatebin.js index c3394daf..15531838 100644 --- a/js/privatebin.js +++ b/js/privatebin.js @@ -930,7 +930,8 @@ jQuery.PrivateBin = (function($) { } // load strings from JSON - $.getJSON('i18n/' + newLanguage + '.json', function(data) { + const cacheBreaker = document.querySelector('script[src^="js/privatebin.js"]').getAttribute('src').split('.js')[1] || ''; + $.getJSON('i18n/' + newLanguage + '.json' + cacheBreaker, function(data) { language = newLanguage; translations = data; $(document).triggerHandler(languageLoadedEvent); @@ -5160,7 +5161,7 @@ jQuery.PrivateBin = (function($) { // UI loading state TopNav.hideAllButtons(); - Alert.showLoading('Sending paste…', 'cloud-upload'); + Alert.showLoading('Sending document…', 'cloud-upload'); TopNav.collapseBar(); // get data @@ -5431,7 +5432,7 @@ jQuery.PrivateBin = (function($) { { Alert.hideMessages(); Alert.setCustomHandler(null); - Alert.showLoading('Decrypting paste…', 'cloud-download'); + Alert.showLoading('Decrypting document…', 'cloud-download'); if (typeof paste === 'undefined' || paste.type === 'click') { // get cipher data and wait until it is available @@ -5776,7 +5777,7 @@ jQuery.PrivateBin = (function($) { // Important: This *must not* run Alert.hideMessages() as previous // errors from viewing a document should be shown. TopNav.hideAllButtons(); - Alert.showLoading('Preparing new paste…', 'time'); + Alert.showLoading('Preparing new document…', 'time'); PasteStatus.hideMessages(); PasteViewer.hide(); diff --git a/lib/Configuration.php b/lib/Configuration.php index 56fdac3b..cc06e930 100644 --- a/lib/Configuration.php +++ b/lib/Configuration.php @@ -119,9 +119,9 @@ class Configuration 'js/dark-mode-switch.js' => 'sha512-BhY7dNU14aDN5L+muoUmA66x0CkYUWkQT0nxhKBLP/o2d7jE025+dvWJa4OiYffBGEFgmhrD/Sp+QMkxGMTz2g==', 'js/jquery-3.7.1.js' => 'sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==', 'js/kjua-0.10.0.js' => 'sha512-BYj4xggowR7QD150VLSTRlzH62YPfhpIM+b/1EUEr7RQpdWAGKulxWnOvjFx1FUlba4m6ihpNYuQab51H6XlYg==', - 'js/legacy.js' => 'sha512-08+subq1Lo+r+la5ENqeXiMgNJcVaaTtBIFGkrjziSpvtgCId3Jtin4/OkSdHYSoeztwwIab8uvCzPKHta6puQ==', + 'js/legacy.js' => 'sha512-mXAgFn/DonfPANvPO6Kf08zRKCeQ75jXK69gcUUOpPLFkp3KHnDhnvij8nEJxlutD/670Bfi4RNMG6uEjA4nNQ==', 'js/prettify.js' => 'sha512-puO0Ogy++IoA2Pb9IjSxV1n4+kQkKXYAEUtVzfZpQepyDPyXk8hokiYDS7ybMogYlyyEIwMLpZqVhCkARQWLMg==', - 'js/privatebin.js' => 'sha512-seF8mXWh9E9SBK0qWNfm8RTGneGc05QpDcPyeTk+sg6efB6Edhiu6mvbSDCM4zb/gFqeko7jTYcUSYxhs3UIaA==', + 'js/privatebin.js' => 'sha512-nMz7sgy/jMKXKAUDcZcsUdVEuKN89Fgf0mvD7DB70pI4R6ExJwCOWaiQTc/czhVVUEwKzbhWrJG/hX2Tqhe5QA==', 'js/purify-3.2.6.js' => 'sha512-zqwL4OoBLFx89QPewkz4Lz5CSA2ktU+f31fuECkF0iK3Id5qd3Zpq5dMby8KwHjIEpsUgOqwF58cnmcaNem0EA==', 'js/showdown-2.1.0.js' => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==', 'js/zlib-1.3.1-1.js' => 'sha512-5bU9IIP4PgBrOKLZvGWJD4kgfQrkTz8Z3Iqeu058mbQzW3mCumOU6M3UVbVZU9rrVoVwaW4cZK8U8h5xjF88eQ==', diff --git a/lib/Proxy/AbstractProxy.php b/lib/Proxy/AbstractProxy.php index 77e918ff..e45f5377 100644 --- a/lib/Proxy/AbstractProxy.php +++ b/lib/Proxy/AbstractProxy.php @@ -49,7 +49,14 @@ abstract class AbstractProxy */ public function __construct(Configuration $conf, string $link) { - if (!str_starts_with($link, $conf->getKey('basepath') . '?')) { + if (!filter_var($link, FILTER_VALIDATE_URL, FILTER_FLAG_PATH_REQUIRED | FILTER_FLAG_QUERY_REQUIRED)) { + $this->_error = 'Invalid URL given.'; + return; + } + + if (!str_starts_with($link, $conf->getKey('basepath') . '?') || + parse_url($link, PHP_URL_HOST) != parse_url($conf->getKey('basepath'), PHP_URL_HOST) + ) { $this->_error = 'Trying to shorten a URL that isn\'t pointing at our instance.'; return; } diff --git a/tst/YourlsProxyTest.php b/tst/YourlsProxyTest.php index 9ceeea35..a4a87bd9 100644 --- a/tst/YourlsProxyTest.php +++ b/tst/YourlsProxyTest.php @@ -45,22 +45,76 @@ class YourlsProxyTest extends TestCase $yourls = new YourlsProxy($this->_conf, 'https://example.com/?foo#bar'); $this->assertFalse($yourls->isError()); $this->assertEquals($yourls->getUrl(), 'https://example.com/1'); + + $yourls = new YourlsProxy($this->_conf, 'https://example.com/?@foreign.malicious.example?foo#bar'); + $this->assertFalse($yourls->isError()); + $this->assertEquals($yourls->getUrl(), 'https://example.com/1'); } - public function testForeignUrl() + /** + * @dataProvider providerInvalidUrl + */ + public function testImvalidUrl($url): void { - $yourls = new YourlsProxy($this->_conf, 'https://other.example.com/?foo#bar'); + $yourls = new YourlsProxy($this->_conf, $url); + $this->assertTrue($yourls->isError()); + $this->assertEquals($yourls->getError(), 'Invalid URL given.'); + } + + public function providerInvalidUrl(): array + { + return array( + array(''), + array(' '), + array('foo'), + array('https://'), + array('https://example.com'), // missing path and query parameter, + array('https://example.com/'), // missing query parameter + array('https://example.com?paste=something'), // missing path parameter + array('https://example.com@foreign.malicious.example?foo#bar'), // missing path parameter + ); + } + + /** + * This tests for a trick using username of an URI, see: + * {@see https://cloud.google.com/blog/topics/threat-intelligence/url-obfuscation-schema-abuse/?hl=en} + * + * @dataProvider providerForeignUrlUsernameTrick + */ + public function testForeignUrlUsingUsernameTrick($url): void + { + $yourls = new YourlsProxy($this->_conf, $url); $this->assertTrue($yourls->isError()); $this->assertEquals($yourls->getError(), 'Trying to shorten a URL that isn\'t pointing at our instance.'); } - public function testSneakyForeignUrl() + public function providerForeignUrlUsernameTrick(): array { - $yourls = new YourlsProxy($this->_conf, 'https://other.example.com/?q=https://example.com/?foo#bar'); + return array( + array('https://example.com@foreign.malicious.example/?foo#bar'), + array('https://example.com/@foreign.malicious.example?foo#bar'), + ); + } + + /** + * @dataProvider providerForeignUrl + */ + public function testForeignUrl($url): void + { + $yourls = new YourlsProxy($this->_conf, $url); $this->assertTrue($yourls->isError()); $this->assertEquals($yourls->getError(), 'Trying to shorten a URL that isn\'t pointing at our instance.'); } + public function providerForeignUrl(): array + { + return array( + array('ftp://example.com/?n=np'), // wrong protocol + array('https://other.example.com/?foo#bar'), // wrong domain + array('https://other.example.com/?q=https://example.com/?foo#bar'), // domain included inside string + ); + } + public function testYourlsError() { // when statusCode is not 200, shorturl may not have been set