Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Torrentarr.Host/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2715,6 +2715,10 @@ static IResult SaveAndRespondConfigUpdate(
TorrentarrConfig updatedConfig,
ConfigurationLoader loader)
{
var passwordHashError = WebUIAuthHelpers.ValidatePasswordHashForConfigApiSave(cfg, updatedConfig);
if (passwordHashError != null)
return Results.Json(new { error = passwordHashError }, statusCode: 403);

var (reloadType, affectedInstancesList) = DetermineReloadType(cfg, updatedConfig);

loader.SaveConfig(updatedConfig);
Expand Down
25 changes: 24 additions & 1 deletion src/Torrentarr.Infrastructure/Services/WebUIAuthHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,32 @@ public static class WebUIAuthHelpers
if (string.Equals(newValue, RedactedPlaceholder, StringComparison.Ordinal))
return null;

return "Password must be changed via POST /web/auth/set-password";
return PasswordHashChangeRejectedMessage;
}

/// <summary>
/// Config API saves must not change PasswordHash (full replace, section merge, or dotted keys).
/// Restores <see cref="RedactedPlaceholder"/> from the current config; rejects any other change.
/// </summary>
public static string? ValidatePasswordHashForConfigApiSave(TorrentarrConfig current, TorrentarrConfig updated)
{
var currentHash = current.WebUI.PasswordHash ?? "";
var proposedHash = updated.WebUI.PasswordHash ?? "";

if (proposedHash == RedactedPlaceholder)
{
updated.WebUI.PasswordHash = currentHash;
return null;
}

if (currentHash == proposedHash)
return null;

return PasswordHashChangeRejectedMessage;
}

private const string PasswordHashChangeRejectedMessage = "Password must be changed via POST /web/auth/set-password";

/// <summary>Constant-time token comparison using SHA-256 hashes to avoid leaking length.</summary>
public static bool TokenEquals(string? a, string? b)
{
Expand Down
8 changes: 8 additions & 0 deletions src/Torrentarr.WebUI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,10 @@ static string EscalateReloadType(string current, string candidate, string[] tier
}

var updatedConfig = FlatToConfig(flatConfig, config);
var savePasswordHashError = WebUIAuthHelpers.ValidatePasswordHashForConfigApiSave(config, updatedConfig);
if (savePasswordHashError != null)
return Results.Json(new { error = savePasswordHashError }, statusCode: 403);

loader.SaveConfig(updatedConfig, reloader.ConfigPath);
ApplyConfigInPlace(config, updatedConfig);
}
Expand Down Expand Up @@ -1495,6 +1499,10 @@ static bool IsValidLogFileName(string name) =>
{
try
{
var passwordHashError = WebUIAuthHelpers.ValidatePasswordHashForConfigApiSave(config, updatedConfig);
if (passwordHashError != null)
return Results.Json(new { error = passwordHashError }, statusCode: 403);

loader.SaveConfig(updatedConfig, reloader.ConfigPath);
ApplyConfigInPlace(config, updatedConfig);
var reloadSuccess = reloader.ReloadConfig();
Expand Down
38 changes: 38 additions & 0 deletions tests/Torrentarr.Host.Tests/Api/ConfigEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,44 @@ public async Task PostApiConfig_WithEmptyPasswordHash_Returns403_AndPreservesLog
loginAfter.StatusCode.Should().Be(HttpStatusCode.OK);
}

/// <summary>
/// Section-level WebUI replace bypasses dotted-key RejectPasswordHashConfigChange guard.
/// Regression: closed PR #276; #271 only blocked WebUI.PasswordHash dotted keys.
/// </summary>
[Fact]
public async Task PostConfig_WithWholeWebUISectionEmptyPasswordHash_Returns403_AndPreservesLogin()
{
_factory.SetConfigEnv();
var client = _factory.CreateClientWithApiToken();

var getResponse = await client.GetAsync("/web/config");
var configJson = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync()).RootElement;
var webui = new Dictionary<string, object?>();
foreach (var webuiProp in configJson.GetProperty("WebUI").EnumerateObject())
{
webui[webuiProp.Name] = webuiProp.NameEquals("PasswordHash")
? ""
: webuiProp.Value.ValueKind switch
{
JsonValueKind.String => webuiProp.Value.GetString(),
JsonValueKind.Number => webuiProp.Value.GetInt32(),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => webuiProp.Value.ToString()
};
}

var patchResponse = await client.PostAsJsonAsync("/web/config", new { changes = new Dictionary<string, object> { ["WebUI"] = webui } });
patchResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden);

var loginAfter = await client.PostAsJsonAsync("/web/login", new
{
username = LocalAuthWebApplicationFactory.TestUsername,
password = LocalAuthWebApplicationFactory.TestPassword
});
loginAfter.StatusCode.Should().Be(HttpStatusCode.OK);
}

[Fact]
public async Task PostConfig_EmptyPasswordHash_CannotBeUsedForAccountTakeoverViaSetPassword()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,34 @@ public void RejectPasswordHashConfigChange_IgnoresOtherKeys()
WebUIAuthHelpers.RejectPasswordHashConfigChange("WebUI.Port", "8080")
.Should().BeNull();
}

[Fact]
public void ValidatePasswordHashForConfigApiSave_RejectsChangedHash()
{
var current = new TorrentarrConfig { WebUI = { PasswordHash = "existing-hash" } };
var updated = new TorrentarrConfig { WebUI = { PasswordHash = "" } };

WebUIAuthHelpers.ValidatePasswordHashForConfigApiSave(current, updated)
.Should().NotBeNullOrEmpty();
updated.WebUI.PasswordHash.Should().Be("");
}

[Fact]
public void ValidatePasswordHashForConfigApiSave_RestoresRedactedPlaceholder()
{
var current = new TorrentarrConfig { WebUI = { PasswordHash = "existing-hash" } };
var updated = new TorrentarrConfig { WebUI = { PasswordHash = WebUIAuthHelpers.RedactedPlaceholder } };

WebUIAuthHelpers.ValidatePasswordHashForConfigApiSave(current, updated).Should().BeNull();
updated.WebUI.PasswordHash.Should().Be("existing-hash");
}

[Fact]
public void ValidatePasswordHashForConfigApiSave_AllowsUnchangedHash()
{
var current = new TorrentarrConfig { WebUI = { PasswordHash = "existing-hash" } };
var updated = new TorrentarrConfig { WebUI = { PasswordHash = "existing-hash" } };

WebUIAuthHelpers.ValidatePasswordHashForConfigApiSave(current, updated).Should().BeNull();
}
}
Loading