Update: drop the API-key requirement from the update trigger
ci / build-test (push) Successful in 1m14s

Owner's call: MeterVault__AllowInAppUpdate is now the whole gate. One click on
the banner, no key, no prompt, and the REST endpoint no longer asks for one
either.

What that means, recorded so it is not rediscovered later: with the flag on,
anything that can reach MeterVault can trigger a rebuild and restart. On the
realistic threat model that is a repeatable denial of service — minutes of
downtime and a pegged CPU per request — rather than code injection, because the
build comes from the owner's own repository. It becomes remote code execution
if that repository is ever compromised. The flag still defaults off, and that
default is now the only thing between an upgrade and an open trigger, so
UpdateRunnerTests pins it along with the fact that configuring API keys does not
imply consent to rebuild the host.

Kept one guard, which is not authentication: the REST endpoint requires an
X-MeterVault-Update header. Without it any website could POST to the endpoint
through the browser of someone on the network — a plain HTML form is enough,
and no key means nothing else would stop it. A form cannot set a custom header
and a cross-origin fetch that tries is stopped by a preflight nothing here
answers, so this costs a deliberate caller one flag and costs the button
nothing, since it runs over the Blazor circuit rather than HTTP.

The confirmation dialog stays, now purely as a guard against a stray click
costing several minutes of downtime. Every triggered update is logged as a
warning: with no key there is no caller to attribute it to, and the restart
discards anything held in memory.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
This commit is contained in:
2026-07-18 20:39:12 +02:00
parent 9eb3f7d53c
commit 1f575c9da2
7 changed files with 68 additions and 154 deletions
+10 -14
View File
@@ -12,25 +12,21 @@ public sealed class ApiTests(TimescaleFixture fx)
private sealed record ReadingPush(int MeterId, DateTimeOffset Time, double Value);
[Fact]
public async Task Update_endpoint_is_shut_by_default_and_not_openable_anonymously()
public async Task Update_endpoint_is_shut_unless_explicitly_enabled()
{
// Through the real pipeline, not just the runner: an install that never opted in must return
// a refusal for this endpoint even when the caller presents a valid key, and even when the
// API itself has been opened anonymously. 409 rather than 401 — the endpoint is disabled,
// which is a different fact from the caller being unauthenticated.
// Through the real pipeline, not just the runner. 409 rather than 401: the endpoint is
// disabled, which is a different fact from the caller being unauthenticated — and the
// default install must refuse regardless of what the caller presents.
using var factory = new MeterVaultAppFactory(fx.ConnectionString, configureApiKey: true);
using var client = factory.CreateClient();
using var withKey = new HttpRequestMessage(HttpMethod.Post, "/api/v1/system/update");
withKey.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
var authorised = await client.SendAsync(withKey);
Assert.Equal(HttpStatusCode.Conflict, authorised.StatusCode);
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/system/update");
request.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
request.Headers.Add(MeterVault.App.Api.ApiEndpoints.UpdateRequestHeader, "1");
// And without a key it is certainly not reachable.
var anonymous = await client.PostAsync(new Uri("/api/v1/system/update", UriKind.Relative), content: null);
Assert.True(
anonymous.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Conflict,
$"expected the update endpoint to refuse an unauthenticated caller, got {anonymous.StatusCode}.");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
[Fact]
+14 -58
View File
@@ -5,8 +5,9 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace MeterVault.Integration.Tests;
/// <summary>
/// The gate in front of the in-app update. Triggering it runs a build of whatever is on the branch,
/// as root on the LXC, so every one of these is a case where getting it wrong hands the host over.
/// The gate in front of the in-app update. <c>AllowInAppUpdate</c> is the whole gate by explicit
/// operator choice — no key — so the flag defaulting off is the only thing standing between an
/// upgrade and a network-triggerable rebuild-and-restart as root.
/// </summary>
/// <remarks>
/// None of these launch anything: they assert the conditions that must hold <em>before</em> a launch
@@ -15,64 +16,24 @@ namespace MeterVault.Integration.Tests;
/// </remarks>
public sealed class UpdateRunnerTests
{
private const string Key = "correct-horse-battery-staple";
[Fact]
public void Disabled_by_default()
{
// The single most important assertion here: an operator who has not thought about this does
// not get a remote-code-execution endpoint by upgrading.
// The single most important assertion in this file: with no key required, an operator who
// upgrades without reading the notes must not silently acquire an open trigger.
Assert.False(new MeterVaultOptions().AllowInAppUpdate);
Assert.Equal(UpdateAvailability.NotEnabled, NewRunner(new MeterVaultOptions()).Availability);
}
[Fact]
public void Refuses_when_enabled_but_no_api_key_is_configured()
public void Configuring_api_keys_alone_does_not_enable_it()
{
// Otherwise "enabled" would mean anyone at all, since there would be no key to present.
var runner = NewRunner(new MeterVaultOptions { AllowInAppUpdate = true });
// The two settings are independent: having an API key for the REST API is not consent to
// rebuild the host.
var options = new MeterVaultOptions();
options.ApiKeys.Add("some-key");
Assert.Equal(UpdateAvailability.NoApiKeyConfigured, runner.Availability);
}
[Fact]
public void An_anonymous_api_deployment_cannot_reach_it()
{
// AllowAnonymousApi opens reads. It must not open root: with no keys configured this stays
// shut regardless of that flag.
var runner = NewRunner(new MeterVaultOptions
{
AllowInAppUpdate = true,
AllowAnonymousApi = true,
});
Assert.Equal(UpdateAvailability.NoApiKeyConfigured, runner.Availability);
Assert.False(runner.IsAuthorised(null));
Assert.False(runner.IsAuthorised(""));
Assert.False(runner.IsAuthorised("anything"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("wrong")]
[InlineData("correct-horse-battery-stapl")] // prefix of a valid key
[InlineData("correct-horse-battery-staple ")] // trailing space
[InlineData("CORRECT-HORSE-BATTERY-STAPLE")] // case differs
public void Rejects_anything_that_is_not_exactly_a_configured_key(string? provided)
{
Assert.False(NewRunner(Enabled()).IsAuthorised(provided));
}
[Fact]
public void Accepts_an_exact_key_and_any_of_several()
{
var options = Enabled();
options.ApiKeys.Add("second-key");
var runner = NewRunner(options);
Assert.True(runner.IsAuthorised(Key));
Assert.True(runner.IsAuthorised("second-key"));
Assert.Equal(UpdateAvailability.NotEnabled, NewRunner(options).Availability);
}
[Fact]
@@ -88,8 +49,8 @@ public sealed class UpdateRunnerTests
[Fact]
public void Reports_unsupported_where_there_is_no_in_place_updater()
{
// On a dev box or in a container there is no /usr/bin/update, so a fully configured runner
// still declines rather than half-running something.
// On a dev box or in a container there is no /usr/bin/update, so an enabled runner still
// declines rather than half-running something.
if (UpdateRunner.IsSupportedHere)
{
return; // running on a provisioned LXC; the negative case cannot be observed here
@@ -98,12 +59,7 @@ public sealed class UpdateRunnerTests
Assert.Equal(UpdateAvailability.NotSupportedHere, NewRunner(Enabled()).Availability);
}
private static MeterVaultOptions Enabled()
{
var options = new MeterVaultOptions { AllowInAppUpdate = true };
options.ApiKeys.Add(Key);
return options;
}
private static MeterVaultOptions Enabled() => new() { AllowInAppUpdate = true };
private static UpdateRunner NewRunner(MeterVaultOptions options) =>
new(Microsoft.Extensions.Options.Options.Create(options), NullLogger<UpdateRunner>.Instance);