mirror of
https://github.com/PrivateBin/PrivateBin.git
synced 2026-03-05 13:30:32 -05:00
Fixes #1712 Disclosure: Coded with help of Copiot. (description wrtten by me) So this does indeed loosen the encoding a bit. However, IMHO, it was neither better before though. You could always bypass the encoding for `args{0]` when you just include `<a` (or the other tag) somewhere or so. **One important notice:** This was (due to the exceptions before and afterwards) valid before and also now: Translators **could** (and can) if they have malicious intent, inject/do "XSS attacks". Thus, translations PRs (also from Crowdin) should be reviewed for wild HTML code inside translations. I suppose this is easy to fix, but anyway a valid risk. But IMHO, we should teat the JSON files being part of our source code as a "trusted source". In the end, such an attak is basicaly just ends up being injecting malicious code. I hope such contributors would be detected. References I explicitly checked again to not introduce an XSS here: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html and the PHP doc for he HTML encoding. I feel the safter way obviously would be encoding the _whole_ string _after_ translation (just like you should apply DOMPurify after everything), but as explained it was not done before and would break compatibility. Also, I looked through the sources and I see no risk described by doing it only for the "dangerous" "untrusted" inputs. Only here is a notice that `%s` shall not be used in some contexts, for example to define a tag: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#dangerous-contexts (obviously in such a case, attacks may be possible even with encoding; but again; this is nothing new) The basic "problem" of it all is: We want HTML to be translated/be usable in our translation. If we'd get rid of that, we would get for sure rid of all such XSS attack possibilities. But that woud be a bigger refactoring, so IMHO, this here is fine for a fix for the issue at hand. Ah another point: I think the `is_int` check is harmless, but it's also kinda useless. Maybe it is some kind of obscure performance optimisation. (Yeah ints have nothing to encode as they have nothing that could be used for XSS, but they could also just be passed through that function.)
265 lines
12 KiB
PHP
265 lines
12 KiB
PHP
<?php declare(strict_types=1);
|
||
|
||
use PHPUnit\Framework\TestCase;
|
||
use PrivateBin\I18n;
|
||
|
||
class I18nMock extends I18n
|
||
{
|
||
public static function resetAvailableLanguages()
|
||
{
|
||
self::$_availableLanguages = array();
|
||
}
|
||
|
||
public static function resetPath($path = '')
|
||
{
|
||
self::$_path = $path;
|
||
}
|
||
|
||
public static function getPath($file = '')
|
||
{
|
||
return self::_getPath($file);
|
||
}
|
||
}
|
||
|
||
class I18nTest extends TestCase
|
||
{
|
||
private $_translations = array();
|
||
|
||
public function setUp(): void
|
||
{
|
||
/* Setup Routine */
|
||
$this->_translations = json_decode(
|
||
file_get_contents(PATH . 'i18n' . DIRECTORY_SEPARATOR . 'de.json'),
|
||
true
|
||
);
|
||
}
|
||
|
||
public function tearDown(): void
|
||
{
|
||
unset($_COOKIE['lang'], $_SERVER['HTTP_ACCEPT_LANGUAGE']);
|
||
}
|
||
|
||
public function testTranslationFallback()
|
||
{
|
||
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'foobar';
|
||
$messageId = 'It does not matter if the message ID exists';
|
||
I18n::loadTranslations();
|
||
$this->assertEquals($messageId, I18n::_($messageId), 'fallback to en');
|
||
I18n::getLanguageLabels();
|
||
}
|
||
|
||
public function testCookieLanguageDeDetection()
|
||
{
|
||
$_COOKIE['lang'] = 'de';
|
||
I18n::loadTranslations();
|
||
$this->assertEquals($_COOKIE['lang'], I18n::getLanguage(), 'browser language de');
|
||
$this->assertEquals('0 Stunden', I18n::_('%d hours', 0), '0 hours in German');
|
||
$this->assertEquals('1 Stunde', I18n::_('%d hours', 1), '1 hour in German');
|
||
$this->assertEquals('2 Stunden', I18n::_('%d hours', 2), '2 hours in German');
|
||
}
|
||
|
||
public function testBrowserLanguageDeDetection()
|
||
{
|
||
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'de-CH,de;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2,fr;q=0.0';
|
||
I18n::loadTranslations();
|
||
$this->assertEquals('de', I18n::getLanguage(), 'browser language de');
|
||
$this->assertEquals('0 Stunden', I18n::_('%d hours', 0), '0 hours in German');
|
||
$this->assertEquals('1 Stunde', I18n::_('%d hours', 1), '1 hour in German');
|
||
$this->assertEquals('2 Stunden', I18n::_('%d hours', 2), '2 hours in German');
|
||
}
|
||
|
||
public function testBrowserLanguageFrDetection()
|
||
{
|
||
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'fr-CH,fr;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2,de;q=0.0';
|
||
I18n::loadTranslations();
|
||
$this->assertEquals('fr', I18n::getLanguage(), 'browser language fr');
|
||
$this->assertEquals('0 heure', I18n::_('%d hours', 0), '0 hours in French');
|
||
$this->assertEquals('1 heure', I18n::_('%d hours', 1), '1 hour in French');
|
||
$this->assertEquals('2 heures', I18n::_('%d hours', 2), '2 hours in French');
|
||
}
|
||
|
||
public function testBrowserLanguageNoDetection()
|
||
{
|
||
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'no;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2';
|
||
I18n::loadTranslations();
|
||
$this->assertEquals('no', I18n::getLanguage(), 'browser language no');
|
||
$this->assertEquals('0 timer', I18n::_('%d hours', 0), '0 hours in Norwegian');
|
||
$this->assertEquals('1 time', I18n::_('%d hours', 1), '1 hour in Norwegian');
|
||
$this->assertEquals('2 timer', I18n::_('%d hours', 2), '2 hours in Norwegian');
|
||
}
|
||
|
||
public function testBrowserLanguageOcDetection()
|
||
{
|
||
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'oc;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2';
|
||
I18n::loadTranslations();
|
||
$this->assertEquals('oc', I18n::getLanguage(), 'browser language oc');
|
||
$this->assertEquals('0 ora', I18n::_('%d hours', 0), '0 hours in Occitan');
|
||
$this->assertEquals('1 ora', I18n::_('%d hours', 1), '1 hour in Occitan');
|
||
$this->assertEquals('2 oras', I18n::_('%d hours', 2), '2 hours in Occitan');
|
||
}
|
||
|
||
public function testBrowserLanguageZhDetection()
|
||
{
|
||
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'zh;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2';
|
||
I18n::loadTranslations();
|
||
$this->assertEquals('zh', I18n::getLanguage(), 'browser language zh');
|
||
$this->assertEquals('0 小时', I18n::_('%d hours', 0), '0 hours in Chinese');
|
||
$this->assertEquals('1 小时', I18n::_('%d hours', 1), '1 hour in Chinese');
|
||
$this->assertEquals('2 小时', I18n::_('%d hours', 2), '2 hours in Chinese');
|
||
}
|
||
|
||
public function testBrowserLanguagePlDetection()
|
||
{
|
||
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'pl;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2';
|
||
I18n::loadTranslations();
|
||
$this->assertEquals('pl', I18n::getLanguage(), 'browser language pl');
|
||
$this->assertEquals('1 godzina', I18n::_('%d hours', 1), '1 hour in Polish');
|
||
$this->assertEquals('2 godziny', I18n::_('%d hours', 2), '2 hours in Polish');
|
||
$this->assertEquals('12 godzin', I18n::_('%d hours', 12), '12 hours in Polish');
|
||
$this->assertEquals('22 godziny', I18n::_('%d hours', 22), '22 hours in Polish');
|
||
$this->assertEquals('1 minuta', I18n::_('%d minutes', 1), '1 minute in Polish');
|
||
$this->assertEquals('3 minuty', I18n::_('%d minutes', 3), '3 minutes in Polish');
|
||
$this->assertEquals('13 minut', I18n::_('%d minutes', 13), '13 minutes in Polish');
|
||
$this->assertEquals('23 minuty', I18n::_('%d minutes', 23), '23 minutes in Polish');
|
||
}
|
||
|
||
public function testBrowserLanguageRuDetection()
|
||
{
|
||
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'ru;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2';
|
||
I18n::loadTranslations();
|
||
$this->assertEquals('ru', I18n::getLanguage(), 'browser language ru');
|
||
$this->assertEquals('1 минуту', I18n::_('%d minutes', 1), '1 minute in Russian');
|
||
$this->assertEquals('3 минуты', I18n::_('%d minutes', 3), '3 minutes in Russian');
|
||
$this->assertEquals('10 минут', I18n::_('%d minutes', 10), '10 minutes in Russian');
|
||
$this->assertEquals('21 минуту', I18n::_('%d minutes', 21), '21 minutes in Russian');
|
||
}
|
||
|
||
public function testBrowserLanguageSlDetection()
|
||
{
|
||
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'sl;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2';
|
||
I18n::loadTranslations();
|
||
$this->assertEquals('sl', I18n::getLanguage(), 'browser language sl');
|
||
$this->assertEquals('0 ura', I18n::_('%d hours', 0), '0 hours in Slowene');
|
||
$this->assertEquals('1 uri', I18n::_('%d hours', 1), '1 hour in Slowene');
|
||
$this->assertEquals('2 ure', I18n::_('%d hours', 2), '2 hours in Slowene');
|
||
$this->assertEquals('3 ur', I18n::_('%d hours', 3), '3 hours in Slowene');
|
||
$this->assertEquals('11 ura', I18n::_('%d hours', 11), '11 hours in Slowene');
|
||
$this->assertEquals('101 uri', I18n::_('%d hours', 101), '101 hours in Slowene');
|
||
$this->assertEquals('102 ure', I18n::_('%d hours', 102), '102 hours in Slowene');
|
||
$this->assertEquals('104 ur', I18n::_('%d hours', 104), '104 hours in Slowene');
|
||
}
|
||
|
||
public function testBrowserLanguageCsDetection()
|
||
{
|
||
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'cs;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2';
|
||
I18n::loadTranslations();
|
||
$this->assertEquals('cs', I18n::getLanguage(), 'browser language cs');
|
||
$this->assertEquals('1 hodina', I18n::_('%d hours', 1), '1 hour in Czech');
|
||
$this->assertEquals('2 hodiny', I18n::_('%d hours', 2), '2 hours in Czech');
|
||
$this->assertEquals('5 minut', I18n::_('%d minutes', 5), '5 minutes in Czech');
|
||
$this->assertEquals('14 minut', I18n::_('%d minutes', 14), '14 minutes in Czech');
|
||
}
|
||
|
||
public function testBrowserLanguageAnyDetection()
|
||
{
|
||
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = '*';
|
||
I18n::loadTranslations();
|
||
$this->assertTrue(strlen(I18n::getLanguage()) >= 2, 'browser language any');
|
||
}
|
||
|
||
public function testVariableInjection()
|
||
{
|
||
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'foobar';
|
||
I18n::loadTranslations();
|
||
$this->assertEquals('some string + 1', I18n::_('some %s + %d', 'string', 1), 'browser language en');
|
||
}
|
||
|
||
public function testHtmlEntityEncoding()
|
||
{
|
||
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'foobar';
|
||
I18n::loadTranslations();
|
||
$input = '&<>"\'/`=';
|
||
$result = htmlspecialchars($input, ENT_QUOTES | ENT_HTML5 | ENT_DISALLOWED, 'UTF-8', false);
|
||
$this->assertEquals($result, I18n::encode($input), 'encodes HTML entities');
|
||
$this->assertEquals('<a>some ' . $result . ' + 1</a>', I18n::_('<a>some %s + %d</a>', $input, 1), 'encodes parameters in translations');
|
||
// Message ID should NOT be encoded (it comes from trusted source), only the parameter should be
|
||
$this->assertEquals($input . $result, I18n::_($input . '%s', $input), 'encodes only parameters, not message ID');
|
||
}
|
||
|
||
public function testFrenchApostropheInMessage()
|
||
{
|
||
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'fr';
|
||
I18n::loadTranslations();
|
||
// The French translation should not have the apostrophe encoded
|
||
// Original: "Le document n'existe pas, a expiré, ou a été supprimé."
|
||
// Should NOT become: "Le document n'existe pas, a expiré, ou a été supprimé."
|
||
$message = I18n::_('Document does not exist, has expired or has been deleted.');
|
||
$this->assertFalse(strpos($message, ''') !== false, 'French apostrophe should not be encoded in translation message');
|
||
$this->assertTrue(strpos($message, "n'existe") !== false, 'French apostrophe should be present as literal character');
|
||
}
|
||
|
||
public function testFallbackAlwaysPresent()
|
||
{
|
||
$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'privatebin_i18n';
|
||
if (!is_dir($path)) {
|
||
mkdir($path);
|
||
}
|
||
|
||
$languageIterator = new AppendIterator();
|
||
$languageIterator->append(new GlobIterator(I18nMock::getPath('??.json')));
|
||
$languageIterator->append(new GlobIterator(I18nMock::getPath('???.json'))); // for jbo
|
||
$languageCount = 0;
|
||
foreach ($languageIterator as $file) {
|
||
++$languageCount;
|
||
$this->assertTrue(copy($file->getPathname(), $path . DIRECTORY_SEPARATOR . $file->getBasename()));
|
||
}
|
||
|
||
I18nMock::resetPath($path);
|
||
$languagesDevelopment = I18nMock::getAvailableLanguages();
|
||
$this->assertEquals($languageCount, count($languagesDevelopment), 'all copied languages detected');
|
||
$this->assertTrue(in_array('en', $languagesDevelopment), 'English fallback present');
|
||
|
||
unlink($path . DIRECTORY_SEPARATOR . 'en.json');
|
||
I18nMock::resetAvailableLanguages();
|
||
$languagesDeployed = I18nMock::getAvailableLanguages();
|
||
$this->assertEquals($languageCount, count($languagesDeployed), 'all copied languages detected, plus fallback');
|
||
$this->assertTrue(in_array('en', $languagesDeployed), 'English fallback still present');
|
||
|
||
I18nMock::resetAvailableLanguages();
|
||
I18nMock::resetPath();
|
||
Helper::rmDir($path);
|
||
}
|
||
|
||
public function testMessageIdsExistInAllLanguages()
|
||
{
|
||
$messageIds = array();
|
||
$languages = array();
|
||
$dir = dir(PATH . 'i18n');
|
||
while (false !== ($file = $dir->read())) {
|
||
if (strlen($file) === 7) {
|
||
$language = substr($file, 0, 2);
|
||
$languageMessageIds = array_keys(
|
||
json_decode(
|
||
file_get_contents(PATH . 'i18n' . DIRECTORY_SEPARATOR . $file),
|
||
true
|
||
)
|
||
);
|
||
$messageIds = array_unique(array_merge($messageIds, $languageMessageIds));
|
||
$languages[$language] = $languageMessageIds;
|
||
}
|
||
}
|
||
foreach ($messageIds as $messageId) {
|
||
foreach (array_keys($languages) as $language) {
|
||
// most languages don't translate the data size units, ignore those
|
||
if ($messageId !== 'B' && strlen($messageId) !== 3 && strpos($messageId, 'B', 2) !== 2) {
|
||
$this->assertContains(
|
||
$messageId,
|
||
$languages[$language],
|
||
"message ID '$messageId' exists in translation file $language.json"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|