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.)
452 lines
14 KiB
PHP
452 lines
14 KiB
PHP
<?php declare(strict_types=1);
|
|
/**
|
|
* PrivateBin
|
|
*
|
|
* a zero-knowledge paste bin
|
|
*
|
|
* @link https://github.com/PrivateBin/PrivateBin
|
|
* @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
|
|
* @license https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
|
|
*/
|
|
|
|
namespace PrivateBin;
|
|
|
|
use AppendIterator;
|
|
use GlobIterator;
|
|
|
|
/**
|
|
* I18n
|
|
*
|
|
* provides internationalization tools like translation, browser language detection, etc.
|
|
*/
|
|
class I18n
|
|
{
|
|
/**
|
|
* language
|
|
*
|
|
* @access protected
|
|
* @static
|
|
* @var string
|
|
*/
|
|
protected static $_language = 'en';
|
|
|
|
/**
|
|
* language fallback
|
|
*
|
|
* @access protected
|
|
* @static
|
|
* @var string
|
|
*/
|
|
protected static $_languageFallback = 'en';
|
|
|
|
/**
|
|
* language labels
|
|
*
|
|
* @access protected
|
|
* @static
|
|
* @var array
|
|
*/
|
|
protected static $_languageLabels = array();
|
|
|
|
/**
|
|
* available languages
|
|
*
|
|
* @access protected
|
|
* @static
|
|
* @var array
|
|
*/
|
|
protected static $_availableLanguages = array();
|
|
|
|
/**
|
|
* path to language files
|
|
*
|
|
* @access protected
|
|
* @static
|
|
* @var string
|
|
*/
|
|
protected static $_path = '';
|
|
|
|
/**
|
|
* translation cache
|
|
*
|
|
* @access protected
|
|
* @static
|
|
* @var array
|
|
*/
|
|
protected static $_translations = array();
|
|
|
|
/**
|
|
* translate a string, alias for translate()
|
|
*
|
|
* @access public
|
|
* @static
|
|
* @param string|array $messageId
|
|
* @param mixed $args one or multiple parameters injected into placeholders
|
|
* @return string
|
|
*/
|
|
public static function _($messageId, ...$args)
|
|
{
|
|
return forward_static_call_array('PrivateBin\I18n::translate', func_get_args());
|
|
}
|
|
|
|
/**
|
|
* translate a string
|
|
*
|
|
* @access public
|
|
* @static
|
|
* @param string|array $messageId
|
|
* @param mixed $args one or multiple parameters injected into placeholders
|
|
* @return string
|
|
*/
|
|
public static function translate($messageId, ...$args)
|
|
{
|
|
if (empty($messageId)) {
|
|
return $messageId;
|
|
}
|
|
if (empty(self::$_translations)) {
|
|
self::loadTranslations();
|
|
}
|
|
$messages = $messageId;
|
|
if (is_array($messageId)) {
|
|
$messageId = count($messageId) > 1 ? $messageId[1] : $messageId[0];
|
|
}
|
|
if (!array_key_exists($messageId, self::$_translations)) {
|
|
self::$_translations[$messageId] = $messages;
|
|
}
|
|
array_unshift($args, $messageId);
|
|
if (is_array(self::$_translations[$messageId])) {
|
|
$number = (int) $args[1];
|
|
$key = self::_getPluralForm($number);
|
|
$max = count(self::$_translations[$messageId]) - 1;
|
|
if ($key > $max) {
|
|
$key = $max;
|
|
}
|
|
|
|
$args[0] = self::$_translations[$messageId][$key];
|
|
$args[1] = $number;
|
|
} else {
|
|
$args[0] = self::$_translations[$messageId];
|
|
}
|
|
// encode any non-integer arguments, but not the message itself
|
|
// The message ID comes from trusted sources (code or translation JSON files),
|
|
// while parameters may come from untrusted sources and need HTML entity encoding
|
|
// to prevent XSS attacks when the message is inserted into HTML context
|
|
$argsCount = count($args);
|
|
for ($i = 1; $i < $argsCount; ++$i) {
|
|
if (is_int($args[$i])) {
|
|
continue;
|
|
}
|
|
$args[$i] = self::encode($args[$i]);
|
|
}
|
|
return call_user_func_array('sprintf', $args);
|
|
}
|
|
|
|
/**
|
|
* encode HTML entities for output into an HTML5 document
|
|
*
|
|
* @access public
|
|
* @static
|
|
* @param string $string
|
|
* @return string
|
|
*/
|
|
public static function encode($string)
|
|
{
|
|
return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5 | ENT_DISALLOWED, 'UTF-8', false);
|
|
}
|
|
|
|
/**
|
|
* loads translations
|
|
*
|
|
* From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
|
|
*
|
|
* @access public
|
|
* @static
|
|
*/
|
|
public static function loadTranslations()
|
|
{
|
|
$availableLanguages = self::getAvailableLanguages();
|
|
|
|
// check if the lang cookie was set and that language exists
|
|
if (
|
|
array_key_exists('lang', $_COOKIE) &&
|
|
($key = array_search($_COOKIE['lang'], $availableLanguages)) !== false
|
|
) {
|
|
$match = $availableLanguages[$key];
|
|
}
|
|
// find a translation file matching the browsers language preferences
|
|
else {
|
|
$match = self::_getMatchingLanguage(
|
|
self::getBrowserLanguages(), $availableLanguages
|
|
);
|
|
}
|
|
|
|
// load translations
|
|
self::$_language = $match;
|
|
if ($match == 'en') {
|
|
self::$_translations = array();
|
|
} else {
|
|
$data = file_get_contents(self::_getPath($match . '.json'));
|
|
self::$_translations = Json::decode($data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* get list of available translations based on files found
|
|
*
|
|
* @access public
|
|
* @static
|
|
* @return array
|
|
*/
|
|
public static function getAvailableLanguages()
|
|
{
|
|
if (count(self::$_availableLanguages) == 0) {
|
|
self::$_availableLanguages[] = 'en'; // en.json is not part of the release archive
|
|
$languageIterator = new AppendIterator();
|
|
$languageIterator->append(new GlobIterator(self::_getPath('??.json')));
|
|
$languageIterator->append(new GlobIterator(self::_getPath('???.json'))); // for jbo
|
|
foreach ($languageIterator as $file) {
|
|
$language = $file->getBasename('.json');
|
|
if ($language != 'en') {
|
|
self::$_availableLanguages[] = $language;
|
|
}
|
|
}
|
|
}
|
|
return self::$_availableLanguages;
|
|
}
|
|
|
|
/**
|
|
* detect the clients supported languages and return them ordered by preference
|
|
*
|
|
* From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
|
|
*
|
|
* @access public
|
|
* @static
|
|
* @return array
|
|
*/
|
|
public static function getBrowserLanguages()
|
|
{
|
|
$languages = array();
|
|
if (array_key_exists('HTTP_ACCEPT_LANGUAGE', $_SERVER)) {
|
|
$languageRanges = explode(',', trim($_SERVER['HTTP_ACCEPT_LANGUAGE']));
|
|
foreach ($languageRanges as $languageRange) {
|
|
if (preg_match(
|
|
'/(\*|[a-zA-Z0-9]{1,8}(?:-[a-zA-Z0-9]{1,8})*)(?:\s*;\s*q\s*=\s*(0(?:\.\d{0,3})|1(?:\.0{0,3})))?/',
|
|
trim($languageRange), $match
|
|
)) {
|
|
if (!isset($match[2])) {
|
|
$match[2] = '1.0';
|
|
} else {
|
|
$match[2] = (string) floatval($match[2]);
|
|
}
|
|
if (!isset($languages[$match[2]])) {
|
|
$languages[$match[2]] = array();
|
|
}
|
|
$languages[$match[2]][] = strtolower($match[1]);
|
|
}
|
|
}
|
|
krsort($languages);
|
|
}
|
|
return $languages;
|
|
}
|
|
|
|
/**
|
|
* get currently loaded language
|
|
*
|
|
* @access public
|
|
* @static
|
|
* @return string
|
|
*/
|
|
public static function getLanguage()
|
|
{
|
|
return self::$_language;
|
|
}
|
|
|
|
/**
|
|
* get list of language labels
|
|
*
|
|
* Only for given language codes, otherwise all labels.
|
|
*
|
|
* @access public
|
|
* @static
|
|
* @param array $languages
|
|
* @return array
|
|
*/
|
|
public static function getLanguageLabels($languages = array())
|
|
{
|
|
$file = self::_getPath('languages.json');
|
|
if (count(self::$_languageLabels) == 0 && is_readable($file)) {
|
|
$data = file_get_contents($file);
|
|
self::$_languageLabels = Json::decode($data);
|
|
}
|
|
if (count($languages) == 0) {
|
|
return self::$_languageLabels;
|
|
}
|
|
return array_intersect_key(self::$_languageLabels, array_flip($languages));
|
|
}
|
|
|
|
/**
|
|
* determines if the current language is written right-to-left (RTL)
|
|
*
|
|
* @access public
|
|
* @static
|
|
* @return bool
|
|
*/
|
|
public static function isRtl()
|
|
{
|
|
return in_array(self::$_language, array('ar', 'he'));
|
|
}
|
|
|
|
/**
|
|
* set the default language
|
|
*
|
|
* @access public
|
|
* @static
|
|
* @param string $lang
|
|
*/
|
|
public static function setLanguageFallback($lang)
|
|
{
|
|
if (in_array($lang, self::getAvailableLanguages())) {
|
|
self::$_languageFallback = $lang;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* get language file path
|
|
*
|
|
* @access protected
|
|
* @static
|
|
* @param string $file
|
|
* @return string
|
|
*/
|
|
protected static function _getPath($file = '')
|
|
{
|
|
if (empty(self::$_path)) {
|
|
self::$_path = PUBLIC_PATH . DIRECTORY_SEPARATOR . 'i18n';
|
|
}
|
|
return self::$_path . (empty($file) ? '' : DIRECTORY_SEPARATOR . $file);
|
|
}
|
|
|
|
/**
|
|
* determines the plural form to use based on current language and given number
|
|
*
|
|
* From: https://docs.translatehouse.org/projects/localization-guide/en/latest/l10n/pluralforms.html
|
|
*
|
|
* @access protected
|
|
* @static
|
|
* @param int $n
|
|
* @return int
|
|
*/
|
|
protected static function _getPluralForm($n)
|
|
{
|
|
switch (self::$_language) {
|
|
case 'ar':
|
|
return $n === 0 ? 0 : ($n === 1 ? 1 : ($n === 2 ? 2 : ($n % 100 >= 3 && $n % 100 <= 10 ? 3 : ($n % 100 >= 11 ? 4 : 5))));
|
|
case 'cs':
|
|
case 'sk':
|
|
return $n === 1 ? 0 : ($n >= 2 && $n <= 4 ? 1 : 2);
|
|
case 'co':
|
|
case 'fr':
|
|
case 'oc':
|
|
case 'tr':
|
|
case 'zh':
|
|
return $n > 1 ? 1 : 0;
|
|
case 'he':
|
|
return $n === 1 ? 0 : ($n === 2 ? 1 : (($n < 0 || $n > 10) && ($n % 10 === 0) ? 2 : 3));
|
|
case 'id':
|
|
case 'ja':
|
|
case 'jbo':
|
|
case 'th':
|
|
return 0;
|
|
case 'lt':
|
|
return $n % 10 === 1 && $n % 100 !== 11 ? 0 : (($n % 10 >= 2 && $n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
|
|
case 'pl':
|
|
return $n === 1 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
|
|
case 'ro':
|
|
return $n === 1 ? 0 : (($n === 0 || ($n % 100 > 0 && $n % 100 < 20)) ? 1 : 2);
|
|
case 'ru':
|
|
case 'uk':
|
|
return $n % 10 === 1 && $n % 100 != 11 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
|
|
case 'sl':
|
|
return $n % 100 === 1 ? 1 : ($n % 100 === 2 ? 2 : ($n % 100 === 3 || $n % 100 === 4 ? 3 : 0));
|
|
default:
|
|
// bg, ca, de, el, en, es, et, fi, hu, it, nl, no, pt
|
|
return $n !== 1 ? 1 : 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* compares two language preference arrays and returns the preferred match
|
|
*
|
|
* From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
|
|
*
|
|
* @access protected
|
|
* @static
|
|
* @param array $acceptedLanguages
|
|
* @param array $availableLanguages
|
|
* @return string
|
|
*/
|
|
protected static function _getMatchingLanguage($acceptedLanguages, $availableLanguages)
|
|
{
|
|
$matches = array();
|
|
$any = false;
|
|
foreach ($acceptedLanguages as $acceptedQuality => $acceptedValues) {
|
|
$acceptedQuality = floatval($acceptedQuality);
|
|
if ($acceptedQuality === 0.0) {
|
|
continue;
|
|
}
|
|
foreach ($availableLanguages as $availableValue) {
|
|
$availableQuality = 1.0;
|
|
foreach ($acceptedValues as $acceptedValue) {
|
|
if ($acceptedValue === '*') {
|
|
$any = true;
|
|
}
|
|
$matchingGrade = self::_matchLanguage($acceptedValue, $availableValue);
|
|
if ($matchingGrade > 0) {
|
|
$q = (string) ($acceptedQuality * $availableQuality * $matchingGrade);
|
|
if (!isset($matches[$q])) {
|
|
$matches[$q] = array();
|
|
}
|
|
if (!in_array($availableValue, $matches[$q])) {
|
|
$matches[$q][] = $availableValue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (count($matches) === 0 && $any) {
|
|
if (count($availableLanguages) > 0) {
|
|
$matches['1.0'] = $availableLanguages;
|
|
}
|
|
}
|
|
if (count($matches) === 0) {
|
|
return self::$_languageFallback;
|
|
}
|
|
krsort($matches);
|
|
$topmatches = current($matches);
|
|
return current($topmatches);
|
|
}
|
|
|
|
/**
|
|
* compare two language IDs and return the degree they match
|
|
*
|
|
* From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
|
|
*
|
|
* @access protected
|
|
* @static
|
|
* @param string $a
|
|
* @param string $b
|
|
* @return float
|
|
*/
|
|
protected static function _matchLanguage($a, $b)
|
|
{
|
|
$a = explode('-', $a);
|
|
$b = explode('-', $b);
|
|
for ($i = 0, $n = min(count($a), count($b)); $i < $n; ++$i) {
|
|
if ($a[$i] !== $b[$i]) {
|
|
break;
|
|
}
|
|
}
|
|
return $i === 0 ? 0 : (float) $i / count($a);
|
|
}
|
|
}
|