Merge pull request #7185 from Abhijay007/feat/updateTranslations
What changed, and why it matters
This commit adds a feature that lets BTCPay Server administrators download and update language translation packs from a GitHub repository. The code fetches JSON translation files over the internet, stores them in the server's database, and tracks whether a newer version is available. There is no clear security bug in the diff, but the design introduces a supply-chain and server-side request risk that should be reviewed carefully.
Treat this as a feature addition with supply-chain exposure rather than a confirmed vulnerability. If auditing, verify that the route is restricted to fully trusted server admins, that downloaded translation JSON is validated before persistence, and that the GitHub repository is the only permitted remote source. Consider adding signature or checksum verification, response size limits, and logging of language-pack downloads/updates.
Security signals we found
Server-side HTTP request to third-party GitHub raw content domain based on admin-supplied language selection
Downloaded JSON is parsed and persisted into database-backed localization dictionaries without visible schema or signature validation
No code signing, checksum verification against a trusted source, or content-type validation is present in the diff
New SQL update path via LocalizerService.UpdateVersion uses parameterized dict_id and version, but metadata is constructed from JObject.ToString() in UpdateDictionaryMetadata
SSRF potential is limited by hard-coded base URL and whitelist of language names, though Uri.EscapeDataString is applied
The feature is admin-only (UIServerController under /server/dictionaries) and protected by existing server admin authorization
Evidence from the diff
The change introduces LanguagePackUpdateService and new controller actions (DownloadLanguagePack, UpdateLanguagePack) that pull translation JSON from https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/translations/{language}.json. It computes a SHA256 hash of the downloaded content as a version identifier, stores translations via LocalizerService.Save, and updates a version field in the lang_dictionaries metadata. The language parameter is now passed through Uri.EscapeDataString before being embedded in the URL, and a hard-coded list of downloadable languages is enforced. The update check caches results for one hour.
Changed components
BTCPayServer/Controllers/UIServerController.Translations.csBTCPayServer/Services/LanguagePackUpdateService.csBTCPayServer/Services/LocalizerService.csBTCPayServer/Hosting/BTCPayServerServices.csBTCPayServer/Views/UIServer/ListDictionaries.cshtmlInspect captured patch +197 / −24
diff --git a/BTCPayServer/Controllers/UIServerController.Translations.cs b/BTCPayServer/Controllers/UIServerController.Translations.cs
index d017208..e2bd34a 100644
--- a/BTCPayServer/Controllers/UIServerController.Translations.cs
+++ b/BTCPayServer/Controllers/UIServerController.Translations.cs
@@ -1,5 +1,6 @@
using System;
using System.Data.Common;
+using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
@@ -13,21 +14,33 @@ namespace BTCPayServer.Controllers
public partial class UIServerController
{
[HttpGet("server/dictionaries")]
- public async Task<IActionResult> ListDictionaries()
+ public async Task<IActionResult> ListDictionaries([FromServices] LanguagePackUpdateService languagePackUpdateService)
{
var dictionaries = await _localizer.GetDictionaries();
var vm = new ListDictionariesViewModel();
+ var downloadableLanguages = LanguagePackUpdateService.GetDownloadableLanguages();
+
foreach (var dictionary in dictionaries)
{
var isSelected = _policiesSettings.LangDictionary == dictionary.DictionaryName ||
(_policiesSettings.LangDictionary is null && dictionary.Source == "Default");
+ var isDownloadedPack = downloadableLanguages.Contains(dictionary.DictionaryName);
+ var updateAvailable = false;
+
+ if (isDownloadedPack && dictionary.Source == "Custom")
+ {
+ updateAvailable = await languagePackUpdateService.CheckForLanguagePackUpdateCached(dictionary.DictionaryName, dictionary.Metadata);
+ }
+
var dict = new ListDictionariesViewModel.DictionaryViewModel
{
Editable = dictionary.Source == "Custom",
Source = dictionary.Source,
DictionaryName = dictionary.DictionaryName,
Fallback = dictionary.Fallback,
- IsSelected = isSelected
+ IsSelected = isSelected,
+ IsDownloadedLanguagePack = isDownloadedPack && dictionary.Source == "Custom",
+ UpdateAvailable = updateAvailable
};
if (isSelected)
vm.Dictionaries.Insert(0, dict);
@@ -126,7 +139,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("server/dictionaries/download")]
- public async Task<IActionResult> DownloadLanguagePack(string language)
+ public async Task<IActionResult> DownloadLanguagePack(string language, [FromServices] LanguagePackUpdateService languagePackUpdateService)
{
if (string.IsNullOrEmpty(language))
{
@@ -135,9 +148,10 @@ namespace BTCPayServer.Controllers
}
string translationsJson;
+ string version;
try
{
- translationsJson = await FetchLanguagePackFromRepository(language);
+ (translationsJson, version) = await FetchLanguagePackFromRepository(language);
}
catch (HttpRequestException ex)
{
@@ -158,19 +172,62 @@ namespace BTCPayServer.Controllers
}
await _localizer.Save(existingDictionary, translations);
+ await _localizer.UpdateVersion(language, version);
+ languagePackUpdateService.InvalidateCache(language);
+ return RedirectToAction(nameof(ListDictionaries));
+ }
+
+ [HttpPost("server/dictionaries/{dictionary}/update")]
+ [ValidateAntiForgeryToken]
+ public async Task<IActionResult> UpdateLanguagePack(string dictionary, [FromServices] LanguagePackUpdateService languagePackUpdateService)
+ {
+ var existingDictionary = await _localizer.GetDictionary(dictionary);
+ if (existingDictionary is null || !LanguagePackUpdateService.GetDownloadableLanguages().Contains(dictionary))
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Dictionary not found or not a downloadable language pack"].Value;
+ return RedirectToAction(nameof(ListDictionaries));
+ }
+
+ string translationsJson;
+ string version;
+ try
+ {
+ (translationsJson, version) = await FetchLanguagePackFromRepository(dictionary);
+ }
+ catch (HttpRequestException ex)
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Failed to update language pack: {0}", ex.Message].Value;
+ return RedirectToAction(nameof(ListDictionaries));
+ }
+
+ var translations = Translations.CreateFromJson(translationsJson);
+ await _localizer.Save(existingDictionary, translations);
+ await _localizer.UpdateVersion(dictionary, version);
+ languagePackUpdateService.InvalidateCache(dictionary);
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Language pack '{0}' updated successfully", dictionary].Value;
return RedirectToAction(nameof(ListDictionaries));
}
- private async Task<string> FetchLanguagePackFromRepository(string language)
+ private async Task<(string translationsJson, string version)> FetchLanguagePackFromRepository(string language)
{
- var fileName = language.ToLowerInvariant();
+ if (!LanguagePackUpdateService.GetDownloadableLanguages().Contains(language))
+ {
+ throw new ArgumentException($"Language '{language}' is not a valid downloadable language pack.", nameof(language));
+ }
+
+ var fileName = Uri.EscapeDataString(language.ToLowerInvariant());
var url = $"https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/translations/{fileName}.json";
var httpClient = HttpClientFactory.CreateClient();
- using var response = await httpClient.GetAsync(url);
- response.EnsureSuccessStatusCode();
+ httpClient.Timeout = TimeSpan.FromSeconds(30);
- return await response.Content.ReadAsStringAsync();
+ var translationsJson = await httpClient.GetStringAsync(url);
+
+ var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(translationsJson));
+ var version = Convert.ToHexString(hash);
+
+ return (translationsJson, version);
}
+
}
}
diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs
index 229507e..6d53833 100644
--- a/BTCPayServer/Hosting/BTCPayServerServices.cs
+++ b/BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -96,6 +96,7 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<IStringLocalizerFactory, LocalizerFactory>();
services.TryAddSingleton<IHtmlLocalizerFactory, LocalizerFactory>();
services.TryAddSingleton<LocalizerService>();
+ services.TryAddSingleton<LanguagePackUpdateService>();
services.TryAddSingleton<ViewLocalizer>();
services.TryAddSingleton<IStringLocalizer>(o => o.GetRequiredService<IStringLocalizerFactory>().Create("", ""));
services.TryAddSingleton<DelayedTaskScheduler>();
diff --git a/BTCPayServer/Models/ServerViewModels/ListDictionariesViewModel.cs b/BTCPayServer/Models/ServerViewModels/ListDictionariesViewModel.cs
index 0908248..7c12d00 100644
--- a/BTCPayServer/Models/ServerViewModels/ListDictionariesViewModel.cs
+++ b/BTCPayServer/Models/ServerViewModels/ListDictionariesViewModel.cs
@@ -11,6 +11,8 @@ public class ListDictionariesViewModel
public string Source { get; set; }
public bool Editable { get; set; }
public bool IsSelected { get; set; }
+ public bool IsDownloadedLanguagePack { get; set; }
+ public bool UpdateAvailable { get; set; }
}
public List<DictionaryViewModel> Dictionaries = [];
diff --git a/BTCPayServer/Services/LanguagePackUpdateService.cs b/BTCPayServer/Services/LanguagePackUpdateService.cs
new file mode 100644
index 0000000..dd179a1
--- /dev/null
+++ b/BTCPayServer/Services/LanguagePackUpdateService.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Collections.Concurrent;
+using System.Linq;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Services
+{
+ public class LanguagePackUpdateService
+ {
+ private readonly ConcurrentDictionary<string, (bool UpdateAvailable, DateTime CheckedAt)> _updateCheckCache = new();
+ private readonly TimeSpan _cacheExpiration = TimeSpan.FromHours(1);
+ private readonly IHttpClientFactory _httpClientFactory;
+
+ public LanguagePackUpdateService(IHttpClientFactory httpClientFactory)
+ {
+ _httpClientFactory = httpClientFactory;
+ }
+
+ public static string[] GetDownloadableLanguages()
+ {
+ return new[]
+ {
+ "Dutch",
+ "French",
+ "German",
+ "Hindi",
+ "Indonesian",
+ "Italian",
+ "Japanese",
+ "Norwegian",
+ "Korean",
+ "Portuguese (Brazil)",
+ "Russian",
+ "Serbian",
+ "Spanish",
+ "Thai",
+ "Turkish"
+ };
+ }
+
+ public async Task<bool> CheckForLanguagePackUpdateCached(string language, JObject metadata)
+ {
+ var cacheKey = language;
+
+ if (_updateCheckCache.TryGetValue(cacheKey, out var cached))
+ {
+ if (DateTime.UtcNow - cached.CheckedAt < _cacheExpiration)
+ {
+ return cached.UpdateAvailable;
+ }
+ }
+
+ var updateAvailable = await CheckForLanguagePackUpdate(language, metadata);
+ _updateCheckCache[cacheKey] = (updateAvailable, DateTime.UtcNow);
+
+ return updateAvailable;
+ }
+
+ public void InvalidateCache(string language)
+ {
+ _updateCheckCache.TryRemove(language, out _);
+ }
+
+ private async Task<bool> CheckForLanguagePackUpdate(string language, JObject metadata)
+ {
+ try
+ {
+ if (!GetDownloadableLanguages().Contains(language))
+ {
+ return false;
+ }
+
+ var fileName = Uri.EscapeDataString(language.ToLowerInvariant());
+ var url = $"https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/translations/{fileName}.json";
+
+ var httpClient = _httpClientFactory.CreateClient();
+ httpClient.Timeout = TimeSpan.FromSeconds(10);
+
+ var remoteContent = await httpClient.GetStringAsync(url);
+
+ var remoteHash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(remoteContent));
+ var remoteVersion = Convert.ToHexString(remoteHash);
+ var localVersion = metadata["version"]?.ToString();
+
+ if (string.IsNullOrEmpty(localVersion))
+ return true;
+
+ return remoteVersion != localVersion;
+ }
+ catch (HttpRequestException)
+ {
+ return false;
+ }
+ catch (TaskCanceledException)
+ {
+ return false;
+ }
+ }
+ }
+}
diff --git a/BTCPayServer/Services/LocalizerService.cs b/BTCPayServer/Services/LocalizerService.cs
index e2eed7a..30e75e8 100644
--- a/BTCPayServer/Services/LocalizerService.cs
+++ b/BTCPayServer/Services/LocalizerService.cs
@@ -197,5 +197,21 @@ namespace BTCPayServer.Services
var db = ctx.Database.GetDbConnection();
await db.ExecuteAsync("DELETE FROM lang_dictionaries WHERE dict_id=@dict_id AND source='Custom'", new { dict_id = dictionary });
}
+
+ public async Task UpdateDictionaryMetadata(string dictionary, JObject metadata)
+ {
+ await using var ctx = _ContextFactory.CreateContext();
+ var db = ctx.Database.GetDbConnection();
+ await db.ExecuteAsync("UPDATE lang_dictionaries SET metadata = @metadata::jsonb WHERE dict_id = @dict_id",
+ new { dict_id = dictionary, metadata = metadata.ToString() });
+ }
+
+ public async Task UpdateVersion(string dictionary, string version)
+ {
+ await using var ctx = _ContextFactory.CreateContext();
+ var db = ctx.Database.GetDbConnection();
+ await db.ExecuteAsync("UPDATE lang_dictionaries SET metadata = jsonb_set(COALESCE(metadata, '{}'::jsonb), '{version}', to_jsonb(@version::text)) WHERE dict_id = @dict_id",
+ new { dict_id = dictionary, version });
+ }
}
}
diff --git a/BTCPayServer/Views/UIServer/ListDictionaries.cshtml b/BTCPayServer/Views/UIServer/ListDictionaries.cshtml
index 2a1a4e5..35bfa42 100644
--- a/BTCPayServer/Views/UIServer/ListDictionaries.cshtml
+++ b/BTCPayServer/Views/UIServer/ListDictionaries.cshtml
@@ -53,6 +53,12 @@
<td>@v.Fallback</td>
<td class="actions-col">
<div class="d-inline-flex align-items-center gap-3">
+ @if (v.IsDownloadedLanguagePack && v.UpdateAvailable)
+ {
+ <form method="post" asp-action="UpdateLanguagePack" asp-route-dictionary="@v.DictionaryName" style="display:inline;">
+ <button type="submit" class="link-success" style="background:none;border:none;padding:0;cursor:pointer;font:inherit;" text-translate="true">Update</button>
+ </form>
+ }
<a asp-action="CreateDictionary" asp-route-fallback="@v.DictionaryName" text-translate="true">Clone</a>
@if (!v.IsSelected)
{
@@ -92,21 +98,10 @@
<label for="language" class="form-label" text-translate="true">Select Language</label>
<select id="language" name="language" class="form-select" required>
<option value="" text-translate="true">Choose a language...</option>
- <option value="Dutch">Dutch</option>
- <option value="French">French</option>
- <option value="German">German</option>
- <option value="Hindi">Hindi</option>
- <option value="Indonesian">Indonesian</option>
- <option value="Italian">Italian</option>
- <option value="Japanese">Japanese</option>
- <option value="Norwegian">Norwegian</option>
- <option value="Korean">Korean</option>
- <option value="Portuguese (Brazil)">Portuguese (Brazil)</option>
- <option value="Russian">Russian</option>
- <option value="Serbian">Serbian</option>
- <option value="Spanish">Spanish</option>
- <option value="Thai">Thai</option>
- <option value="Turkish">Turkish</option>
+ @foreach (var lang in BTCPayServer.Services.LanguagePackUpdateService.GetDownloadableLanguages())
+ {
+ <option value="@lang">@lang</option>
+ }
</select>
</div>
</div>
Why this scored 29/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.