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]