Harden remote web server security
All changes are localized to RemoteWebServer plus two small helpers; the Phase 0 characterization tests confirm routing, JSON/Prometheus shape, and credential pass/fail semantics are otherwise unchanged. - Password hashing: new PasswordHasher uses PBKDF2-HMAC-SHA256 with a per-credential random salt (self-describing pbkdf2$iters$salt$hash). Verify() still accepts the legacy unsalted SHA-256 hex hash and a successful legacy auth transparently upgrades the stored hash, persisted by the view model on save/shutdown. Property renamed PasswordSHA256 -> PasswordHash. - Constant-time comparison: CredentialComparer.FixedTimeEquals for the user name and password hash; both are evaluated fully (no && short-circuit). - No information disclosure: POST failures return a generic message instead of ex.ToString(); detail is logged server-side only. - Bind intent respected: ResolveListenerIp no longer mutates ListenerIp or silently falls back to all-interfaces for a specific configured address (auto/'?'/wildcards still bind all). A bad address now fails Start(). - CORS: removed the Access-Control-Allow-Origin '*' wildcard; common response headers centralized in WriteCommonHeaders. - Prometheus: label values are escaped (EscapePrometheusLabel). 185 tests pass (was 167). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
||||
// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
// Copyright (C) LibreHardwareMonitor and Contributors.
|
||||
|
||||
using LibreHardwareMonitor.Windows.WinUI.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LibreHardwareMonitor.Windows.WinUI.Tests.Services;
|
||||
|
||||
public class CredentialComparerTests
|
||||
{
|
||||
[Fact]
|
||||
public void FixedTimeEquals_EqualStrings_ReturnsTrue()
|
||||
{
|
||||
Assert.True(CredentialComparer.FixedTimeEquals("admin", "admin"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("admin", "Admin")]
|
||||
[InlineData("admin", "root")]
|
||||
[InlineData("admin", "administrator")]
|
||||
[InlineData("", "x")]
|
||||
public void FixedTimeEquals_DifferentStrings_ReturnsFalse(string left, string right)
|
||||
{
|
||||
Assert.False(CredentialComparer.FixedTimeEquals(left, right));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
||||
// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
// Copyright (C) LibreHardwareMonitor and Contributors.
|
||||
|
||||
using LibreHardwareMonitor.Windows.WinUI.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LibreHardwareMonitor.Windows.WinUI.Tests.Services;
|
||||
|
||||
public class PasswordHasherTests
|
||||
{
|
||||
[Fact]
|
||||
public void Hash_ThenVerify_Succeeds()
|
||||
{
|
||||
string hash = PasswordHasher.Hash("correct horse");
|
||||
Assert.True(PasswordHasher.Verify("correct horse", hash, out bool isLegacy));
|
||||
Assert.False(isLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WrongPassword_Fails()
|
||||
{
|
||||
string hash = PasswordHasher.Hash("secret");
|
||||
Assert.False(PasswordHasher.Verify("guess", hash, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_UsesSelfDescribingPbkdf2Format()
|
||||
{
|
||||
string hash = PasswordHasher.Hash("secret");
|
||||
Assert.StartsWith("pbkdf2$", hash);
|
||||
Assert.Equal(4, hash.Split('$').Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_UsesRandomSalt_SoTwoHashesOfSamePasswordDiffer()
|
||||
{
|
||||
Assert.NotEqual(PasswordHasher.Hash("secret"), PasswordHasher.Hash("secret"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_AcceptsLegacySha256_AndReportsLegacy()
|
||||
{
|
||||
string legacy = PasswordHasher.ComputeLegacySha256("secret");
|
||||
Assert.True(PasswordHasher.Verify("secret", legacy, out bool isLegacy));
|
||||
Assert.True(isLegacy);
|
||||
Assert.False(PasswordHasher.Verify("wrong", legacy, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeLegacySha256_KnownVector()
|
||||
{
|
||||
Assert.Equal("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", PasswordHasher.ComputeLegacySha256("abc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_EmptyStoredHash_Fails()
|
||||
{
|
||||
Assert.False(PasswordHasher.Verify("anything", "", out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("pbkdf2$notanumber$c2FsdA==$aGFzaA==")]
|
||||
[InlineData("pbkdf2$100000$not-base64$aGFzaA==")]
|
||||
[InlineData("pbkdf2$100000")]
|
||||
public void Verify_MalformedPbkdf2_Fails(string storedHash)
|
||||
{
|
||||
Assert.False(PasswordHasher.Verify("secret", storedHash, out _));
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,18 @@ public class RemoteWebServerTests
|
||||
Assert.Equal("", RemoteWebServer.GeneratePrometheusResponse(null, LastValueSettings));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GeneratePrometheusResponse_EscapesLabelValues()
|
||||
{
|
||||
ISensor sensor = WithValues(CreateSensor("Core \"0\"", SensorType.Temperature, new Identifier("cpu", "0", "temperature", "0"), 50f), new SensorValue(50f, DateTime.UtcNow));
|
||||
SensorTreeItemViewModel root = BuildRoot("HOST", CreateHardware("CPU", HardwareType.Cpu, new Identifier("cpu", "0"), sensor));
|
||||
|
||||
string output = RemoteWebServer.GeneratePrometheusResponse(root, LastValueSettings);
|
||||
|
||||
// The double quote in the sensor name must be backslash-escaped so it can't break or inject labels.
|
||||
Assert.Contains("Core \\\"0\\\"", output);
|
||||
}
|
||||
|
||||
// ---- ComputeSHA256 (legacy hashing; Phase 2 must keep verifying these) ----
|
||||
|
||||
[Fact]
|
||||
@@ -292,6 +304,30 @@ public class RemoteWebServerTests
|
||||
Assert.False(server.VerifyCredentials(userName, password));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPassword_ProducesPbkdf2HashThatVerifies()
|
||||
{
|
||||
using RemoteWebServer server = CreateServer(authEnabled: true, "admin", "secret");
|
||||
server.SetPassword("newpass");
|
||||
|
||||
Assert.StartsWith("pbkdf2$", server.PasswordHash);
|
||||
Assert.True(server.VerifyCredentials("admin", "newpass"));
|
||||
Assert.False(server.VerifyCredentials("admin", "secret"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyCredentials_LegacyHash_UpgradesToPbkdf2OnSuccess()
|
||||
{
|
||||
// CreateServer seeds the stored hash with the legacy unsalted SHA-256 of the password.
|
||||
using RemoteWebServer server = CreateServer(authEnabled: true, "admin", "secret");
|
||||
Assert.False(server.PasswordHash.StartsWith("pbkdf2$"));
|
||||
|
||||
Assert.True(server.VerifyCredentials("admin", "secret")); // verified via the legacy path...
|
||||
Assert.StartsWith("pbkdf2$", server.PasswordHash); // ...and transparently upgraded
|
||||
|
||||
Assert.True(server.VerifyCredentials("admin", "secret")); // still verifies via the upgraded hash
|
||||
}
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
private static RemoteWebServer CreateServer(bool authEnabled, string userName, string password)
|
||||
|
||||
Reference in New Issue
Block a user