fix(auth): harden public infra routes

This commit is contained in:
Andrew Stoltz
2026-06-04 13:20:16 -05:00
parent 81a3ddac4c
commit 90599b0413
15 changed files with 189 additions and 20 deletions

View File

@@ -13,8 +13,20 @@ public sealed class FleetManifestLintTests
private static readonly HashSet<string> PublicReadOnlyHosts = new(StringComparer.Ordinal)
{
"bluejay.dev",
"brochure.flowercore.io",
"dist.flowercore.io",
"element.flowercore.io",
"erckak.dev",
"flowercore.io",
"flowerinsider.xyz",
"timeforta.co",
"voice-ws.bluejay.dev",
"www.bluejay.dev",
"www.erckak.dev",
"www.flowercore.io",
"www.flowerinsider.xyz",
"www.timeforta.co",
};
// Public hosts that allow a tightly bounded write surface in addition to
@@ -28,10 +40,40 @@ public sealed class FleetManifestLintTests
// same bounded read-write allowlist as the LAN pair.
private static readonly HashSet<string> PublicReadWriteAllowlistHosts = new(StringComparer.Ordinal)
{
"chat.flowercore.io",
"gitea.flowercore.io",
"matrix.flowercore.io",
"telephony.flowercore.io",
"telephony.iamwork.in",
"updatecenter.iamworkin.lan",
"updates.iamworkin.lan",
"update.flowercore.io",
"updates.flowercore.io",
"voice.bluejay.dev",
"webmail.flowercore.io",
};
private static readonly IReadOnlyDictionary<string, string> InfraHealthzProbeDeployments = new Dictionary<string, string>(StringComparer.Ordinal)
{
["andrew"] = "andrew-web",
["dustin"] = "dustin-web",
["erik"] = "erik-web",
["fc-landing"] = "fc-landing",
["fit"] = "fit-web",
["flowercore"] = "flowercore-web",
["pki-web"] = "pki-web",
};
private static readonly IReadOnlyDictionary<string, string> InfraForwardedProtoProbeDeployments = new Dictionary<string, string>(StringComparer.Ordinal)
{
["andrew"] = "andrew-web",
["dustin"] = "dustin-web",
["erik"] = "erik-web",
["fc-landing"] = "fc-landing",
["fit"] = "fit-web",
["flowercore"] = "flowercore-web",
["pki-web"] = "pki-web",
["telephony"] = "telephony-web",
};
private static readonly HashSet<string> ApiKeyProtectedDeployments = new(StringComparer.Ordinal)
@@ -131,8 +173,13 @@ public sealed class FleetManifestLintTests
}))
.Where(entry => PublicReadOnlyHosts.Any(host => entry.Match.Contains($"Host(`{host}`)", StringComparison.Ordinal)))
.Where(entry => !entry.Match.Contains("Method(`GET`)", StringComparison.Ordinal)
|| !entry.Match.Contains("Method(`HEAD`)", StringComparison.Ordinal))
.Select(entry => $"{entry.Document.Descriptor} is missing an explicit GET/HEAD method allowlist.")
|| !entry.Match.Contains("Method(`HEAD`)", StringComparison.Ordinal)
|| entry.Match.Contains("Method(`POST`)", StringComparison.Ordinal)
|| entry.Match.Contains("Method(`PUT`)", StringComparison.Ordinal)
|| entry.Match.Contains("Method(`PATCH`)", StringComparison.Ordinal)
|| entry.Match.Contains("Method(`DELETE`)", StringComparison.Ordinal)
|| entry.Match.Contains("Method(`OPTIONS`)", StringComparison.Ordinal))
.Select(entry => $"{entry.Document.Descriptor} must explicitly allow GET/HEAD only on a public read-only host.")
.ToList();
violations.Should().BeEmpty();
@@ -473,6 +520,49 @@ public sealed class FleetManifestLintTests
violations.Should().BeEmpty();
}
[Fact]
public void AuthSafeInfraHealthzProbes_MustDeclareAnonymousHealthzContract()
{
var violations = InfraHealthzProbeDeployments.SelectMany(expected =>
{
var deployment = AppDocuments(expected.Key)
.Single(document => document.Kind == "Deployment" && document.Name == expected.Value);
var hasHealthzProbe = deployment.MainContainerMappings()
.Any(container => ProbeHttpGetPath(container, "readinessProbe") == "/healthz"
|| ProbeHttpGetPath(container, "startupProbe") == "/healthz"
|| ProbeHttpGetPath(container, "livenessProbe") == "/healthz");
return hasHealthzProbe
&& !string.Equals(PodAnnotation(deployment, "flowercore.io/healthz-auth-policy"), "allow-anonymous", StringComparison.Ordinal)
? new[] { $"{deployment.Descriptor} probes /healthz but lacks flowercore.io/healthz-auth-policy: allow-anonymous." }
: Array.Empty<string>();
}).ToList();
violations.Should().BeEmpty();
}
[Fact]
public void AuthSafeInfraHttpProbes_MustSendForwardedProtoHttpsHeader()
{
var violations = InfraForwardedProtoProbeDeployments.SelectMany(expected =>
{
var deployment = AppDocuments(expected.Key)
.Single(document => document.Kind == "Deployment" && document.Name == expected.Value);
return deployment.MainContainerMappings()
.SelectMany(container => new[] { "startupProbe", "readinessProbe", "livenessProbe" }
.Where(probeKey => ProbeHttpGetPath(container, probeKey) is "/healthz" or "/health")
.Where(probeKey => !string.Equals(ProbeHttpGetHeaderValue(container, probeKey, "X-Forwarded-Proto"), "https", StringComparison.Ordinal))
.Select(probeKey =>
{
var containerName = ManifestNodeExtensions.Scalar(container, "name") ?? "<unnamed>";
return $"{deployment.Descriptor} container '{containerName}' {probeKey} is missing X-Forwarded-Proto=https.";
}));
}).ToList();
violations.Should().BeEmpty();
}
[Fact]
public void Knowledge_OidcEnforcement_MustKeepHealthzAnonymousContractVisibleInManifest()
{
@@ -1015,6 +1105,20 @@ public sealed class FleetManifestLintTests
: null;
}
private static string? ProbeHttpGetHeaderValue(YamlMappingNode container, string probeKey, string name)
{
if (!ManifestNodeExtensions.TryGetMapping(container, probeKey, out var probe)
|| !ManifestNodeExtensions.TryGetMapping(probe, "httpGet", out var httpGet))
{
return null;
}
return ManifestNodeExtensions.MappingSequence(httpGet, "httpHeaders")
.Where(header => string.Equals(ManifestNodeExtensions.Scalar(header, "name"), name, StringComparison.Ordinal))
.Select(header => ManifestNodeExtensions.Scalar(header, "value"))
.SingleOrDefault();
}
private static IReadOnlyList<ManifestDocument> FcDeviceManagementDocuments()
{
return Inventory.Documents