From 90599b0413dd493e97f8eb86c1c1b0d5f1a0a658 Mon Sep 17 00:00:00 2001 From: Andrew Stoltz Date: Thu, 4 Jun 2026 13:20:16 -0500 Subject: [PATCH] fix(auth): harden public infra routes --- apps/andrew/andrew.yaml | 10 +- apps/dustin/dustin.yaml | 10 +- apps/erik/erik.yaml | 10 +- apps/fc-landing/fc-landing.yaml | 12 +- apps/fit/fit.yaml | 10 +- apps/flowercore/flowercore.yaml | 8 ++ apps/gitea-public/gitea-public.yaml | 2 +- apps/mail/mail.yaml | 2 +- apps/matrix/matrix.yaml | 4 +- apps/pki-web/pki-web.yaml | 9 ++ apps/telephony/telephony.yaml | 10 +- apps/traefik-dashboard/traefik-dashboard.yaml | 9 +- apps/voice/voice.yaml | 4 +- apps/zabbix/zabbix.yaml | 1 + .../FleetManifestLintTests.cs | 108 +++++++++++++++++- 15 files changed, 189 insertions(+), 20 deletions(-) diff --git a/apps/andrew/andrew.yaml b/apps/andrew/andrew.yaml index 21ebdda..912249c 100644 --- a/apps/andrew/andrew.yaml +++ b/apps/andrew/andrew.yaml @@ -201,6 +201,8 @@ spec: metadata: labels: app: andrew-web + annotations: + flowercore.io/healthz-auth-policy: "allow-anonymous" spec: containers: - name: nginx @@ -225,12 +227,18 @@ spec: httpGet: path: /healthz port: 80 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: path: /healthz port: 80 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 3 periodSeconds: 5 volumes: @@ -265,7 +273,7 @@ spec: entryPoints: - websecure routes: - - match: Host(`bluejay.dev`) || Host(`www.bluejay.dev`) + - match: (Host(`bluejay.dev`) || Host(`www.bluejay.dev`)) && (Method(`GET`) || Method(`HEAD`)) kind: Rule services: - name: andrew-web diff --git a/apps/dustin/dustin.yaml b/apps/dustin/dustin.yaml index 5a028d3..ba2b982 100644 --- a/apps/dustin/dustin.yaml +++ b/apps/dustin/dustin.yaml @@ -201,6 +201,8 @@ spec: metadata: labels: app: dustin-web + annotations: + flowercore.io/healthz-auth-policy: "allow-anonymous" spec: containers: - name: nginx @@ -225,12 +227,18 @@ spec: httpGet: path: /healthz port: 80 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: path: /healthz port: 80 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 3 periodSeconds: 5 volumes: @@ -265,7 +273,7 @@ spec: entryPoints: - websecure routes: - - match: Host(`timeforta.co`) || Host(`www.timeforta.co`) + - match: (Host(`timeforta.co`) || Host(`www.timeforta.co`)) && (Method(`GET`) || Method(`HEAD`)) kind: Rule services: - name: dustin-web diff --git a/apps/erik/erik.yaml b/apps/erik/erik.yaml index bda8a35..c95b8ac 100644 --- a/apps/erik/erik.yaml +++ b/apps/erik/erik.yaml @@ -201,6 +201,8 @@ spec: metadata: labels: app: erik-web + annotations: + flowercore.io/healthz-auth-policy: "allow-anonymous" spec: containers: - name: nginx @@ -225,12 +227,18 @@ spec: httpGet: path: /healthz port: 80 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: path: /healthz port: 80 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 3 periodSeconds: 5 volumes: @@ -265,7 +273,7 @@ spec: entryPoints: - websecure routes: - - match: Host(`erckak.dev`) || Host(`www.erckak.dev`) + - match: (Host(`erckak.dev`) || Host(`www.erckak.dev`)) && (Method(`GET`) || Method(`HEAD`)) kind: Rule services: - name: erik-web diff --git a/apps/fc-landing/fc-landing.yaml b/apps/fc-landing/fc-landing.yaml index f6d2c34..c824ca4 100644 --- a/apps/fc-landing/fc-landing.yaml +++ b/apps/fc-landing/fc-landing.yaml @@ -203,6 +203,8 @@ spec: metadata: labels: app: fc-landing + annotations: + flowercore.io/healthz-auth-policy: "allow-anonymous" spec: containers: - name: nginx @@ -227,12 +229,18 @@ spec: httpGet: path: /healthz port: 80 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: path: /healthz port: 80 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 3 periodSeconds: 5 volumes: @@ -298,7 +306,7 @@ spec: entryPoints: - websecure routes: - - match: Host(`flowercore.io`) || Host(`www.flowercore.io`) + - match: (Host(`flowercore.io`) || Host(`www.flowercore.io`)) && (Method(`GET`) || Method(`HEAD`)) kind: Rule services: - name: fc-landing @@ -316,7 +324,7 @@ spec: entryPoints: - web routes: - - match: Host(`flowercore.io`) || Host(`www.flowercore.io`) + - match: (Host(`flowercore.io`) || Host(`www.flowercore.io`)) && (Method(`GET`) || Method(`HEAD`)) kind: Rule services: - name: fc-landing diff --git a/apps/fit/fit.yaml b/apps/fit/fit.yaml index 5bec59b..f95242a 100644 --- a/apps/fit/fit.yaml +++ b/apps/fit/fit.yaml @@ -201,6 +201,8 @@ spec: metadata: labels: app: fit-web + annotations: + flowercore.io/healthz-auth-policy: "allow-anonymous" spec: containers: - name: nginx @@ -225,12 +227,18 @@ spec: httpGet: path: /healthz port: 80 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: path: /healthz port: 80 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 3 periodSeconds: 5 volumes: @@ -265,7 +273,7 @@ spec: entryPoints: - websecure routes: - - match: Host(`flowerinsider.xyz`) || Host(`www.flowerinsider.xyz`) + - match: (Host(`flowerinsider.xyz`) || Host(`www.flowerinsider.xyz`)) && (Method(`GET`) || Method(`HEAD`)) kind: Rule services: - name: fit-web diff --git a/apps/flowercore/flowercore.yaml b/apps/flowercore/flowercore.yaml index b5b1b14..43c7844 100644 --- a/apps/flowercore/flowercore.yaml +++ b/apps/flowercore/flowercore.yaml @@ -257,6 +257,8 @@ spec: metadata: labels: app: flowercore-web + annotations: + flowercore.io/healthz-auth-policy: "allow-anonymous" spec: containers: - name: nginx @@ -281,12 +283,18 @@ spec: httpGet: path: /healthz port: 80 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: path: /healthz port: 80 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 3 periodSeconds: 5 volumes: diff --git a/apps/gitea-public/gitea-public.yaml b/apps/gitea-public/gitea-public.yaml index 2f142fd..8618900 100644 --- a/apps/gitea-public/gitea-public.yaml +++ b/apps/gitea-public/gitea-public.yaml @@ -11,7 +11,7 @@ spec: entryPoints: - websecure routes: - - match: Host(`gitea.flowercore.io`) + - match: Host(`gitea.flowercore.io`) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`)) kind: Rule services: - name: gitea-http diff --git a/apps/mail/mail.yaml b/apps/mail/mail.yaml index b02f31c..3752883 100644 --- a/apps/mail/mail.yaml +++ b/apps/mail/mail.yaml @@ -243,7 +243,7 @@ spec: entryPoints: - websecure routes: - - match: Host(`webmail.flowercore.io`) + - match: Host(`webmail.flowercore.io`) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`)) kind: Rule services: - name: mail-webmail diff --git a/apps/matrix/matrix.yaml b/apps/matrix/matrix.yaml index 4865803..5ff0b42 100644 --- a/apps/matrix/matrix.yaml +++ b/apps/matrix/matrix.yaml @@ -479,7 +479,7 @@ spec: entryPoints: - websecure routes: - - match: Host(`element.flowercore.io`) + - match: Host(`element.flowercore.io`) && (Method(`GET`) || Method(`HEAD`)) kind: Rule services: - name: element-web @@ -497,7 +497,7 @@ spec: entryPoints: - websecure routes: - - match: Host(`matrix.flowercore.io`) + - match: Host(`matrix.flowercore.io`) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`)) kind: Rule services: - name: synapse diff --git a/apps/pki-web/pki-web.yaml b/apps/pki-web/pki-web.yaml index 907283f..cd6d599 100644 --- a/apps/pki-web/pki-web.yaml +++ b/apps/pki-web/pki-web.yaml @@ -134,6 +134,8 @@ spec: metadata: labels: app: pki-web + annotations: + flowercore.io/healthz-auth-policy: "allow-anonymous" spec: containers: - name: nginx @@ -158,12 +160,18 @@ spec: httpGet: path: /healthz port: 80 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: path: /healthz port: 80 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 3 periodSeconds: 5 volumes: @@ -201,6 +209,7 @@ spec: dnsNames: - pki.iamworkin.lan --- +# Internal-only route: if a public twin is ever operator-approved, gate it with Host(``) && (Method(`GET`) || Method(`HEAD`)). # Traefik IngressRoute apiVersion: traefik.io/v1alpha1 kind: IngressRoute diff --git a/apps/telephony/telephony.yaml b/apps/telephony/telephony.yaml index 4ca4de3..f36caab 100644 --- a/apps/telephony/telephony.yaml +++ b/apps/telephony/telephony.yaml @@ -207,12 +207,18 @@ spec: httpGet: path: /health port: 5100 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /health port: 5100 + httpHeaders: + - name: X-Forwarded-Proto + value: https initialDelaySeconds: 10 periodSeconds: 5 volumes: @@ -256,12 +262,12 @@ spec: - websecure routes: - kind: Rule - match: Host(`telephony.flowercore.io`) + match: Host(`telephony.flowercore.io`) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`)) services: - name: telephony-web port: 5100 - kind: Rule - match: Host(`telephony.iamwork.in`) + match: Host(`telephony.iamwork.in`) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`)) services: - name: telephony-web port: 5100 diff --git a/apps/traefik-dashboard/traefik-dashboard.yaml b/apps/traefik-dashboard/traefik-dashboard.yaml index 632a850..c882928 100644 --- a/apps/traefik-dashboard/traefik-dashboard.yaml +++ b/apps/traefik-dashboard/traefik-dashboard.yaml @@ -20,10 +20,11 @@ metadata: spec: basicAuth: secret: traefik-dashboard-auth ---- -# Dashboard IngressRoute -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute +--- +# Internal-only route: if a public twin is ever operator-approved, gate it with Host(``) && (Method(`GET`) || Method(`HEAD`)). +# Dashboard IngressRoute +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute metadata: name: traefik-dashboard namespace: traefik-system diff --git a/apps/voice/voice.yaml b/apps/voice/voice.yaml index 5bbf22b..4a5a5af 100644 --- a/apps/voice/voice.yaml +++ b/apps/voice/voice.yaml @@ -66,7 +66,7 @@ spec: - websecure routes: - kind: Rule - match: Host(`voice.bluejay.dev`) + match: Host(`voice.bluejay.dev`) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`)) services: - name: voice-bridge port: 8766 @@ -84,7 +84,7 @@ spec: - websecure routes: - kind: Rule - match: Host(`voice-ws.bluejay.dev`) + match: Host(`voice-ws.bluejay.dev`) && (Method(`GET`) || Method(`HEAD`)) services: - name: voice-bridge port: 8765 diff --git a/apps/zabbix/zabbix.yaml b/apps/zabbix/zabbix.yaml index 454d024..e491902 100644 --- a/apps/zabbix/zabbix.yaml +++ b/apps/zabbix/zabbix.yaml @@ -344,6 +344,7 @@ spec: dnsNames: - zabbix.iamworkin.lan --- +# Internal-only route: if a public twin is ever operator-approved, gate it with Host(``) && (Method(`GET`) || Method(`HEAD`)). # Traefik IngressRoute apiVersion: traefik.io/v1alpha1 kind: IngressRoute diff --git a/tests/bluejay-infra-lint/FleetManifestLintTests.cs b/tests/bluejay-infra-lint/FleetManifestLintTests.cs index 9c04772..d117b1e 100644 --- a/tests/bluejay-infra-lint/FleetManifestLintTests.cs +++ b/tests/bluejay-infra-lint/FleetManifestLintTests.cs @@ -13,8 +13,20 @@ public sealed class FleetManifestLintTests private static readonly HashSet 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 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 InfraHealthzProbeDeployments = new Dictionary(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 InfraForwardedProtoProbeDeployments = new Dictionary(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 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(); + }).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") ?? ""; + 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 FcDeviceManagementDocuments() { return Inventory.Documents -- 2.49.1