From 71dfbb7eed3581c8503569228f58fcf2aec3cc33 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 8 Nov 2026 12:11:59 +0100 Subject: [PATCH 1/4] fix(coroot): link the Homepage card to the Coroot UI, not /mcp Homepage builds a card's link from the route's first hostname plus the path of its first rule's first match, unless gethomepage.dev/href is set. The /mcp bypass from #5881 became the Coroot route's first rule, so the dashboard card opened https://observability./mcp, where Coroot answers a browser without a bearer token with 401 "unauthorized". Pin the card to the UI root with gethomepage.dev/href, as the OpenBao, Actual Budget and other routes already do. Rule order does not affect Gateway API routing, so nothing flagged the reorder. Add require-homepage-href-for-non-root-routes, a Deny ValidatingPolicy that requires an explicit href on any opted-in HTTPRoute or Ingress whose derived link is not the site root, with a fixture that includes the shipped Coroot route. Co-Authored-By: Claude Opus 5.5 --- docs/TENANTS.md | 6 +- ...ire-homepage-href-for-non-root-routes.yaml | 102 +++++ .../cluster-policies/kustomization.yaml | 1 + .../controllers/coroot/http-route.yaml | 7 + tests/policy-failure-actions.json | 1 + .../kyverno-test.yaml | 60 +++ .../resources.yaml | 372 ++++++++++++++++++ 7 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml create mode 100644 tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml create mode 100644 tests/require-homepage-href-for-non-root-routes/resources.yaml diff --git a/docs/TENANTS.md b/docs/TENANTS.md index fb7029b13c..a6d4ca7216 100644 --- a/docs/TENANTS.md +++ b/docs/TENANTS.md @@ -375,7 +375,11 @@ here** — **hostnames**, **`gethomepage.dev/*` dashboard annotations**, routes, alongside the canonical Kyverno policy fixtures, so neither half can widen silently. - The platform's `homepage` app discovers `gethomepage.dev/*` annotations on the tenant's HTTPRoute cluster-wide, so the tenant authors them in its own artifact — they are tenant - self-presentation, not platform config. + self-presentation, not platform config. Homepage links the card to the route's first + hostname plus its first rule's first path, so a route whose first rule matches a sub-path + (an `/api` or `/mcp` bypass, say) must set `gethomepage.dev/href`; admission denies it + otherwise + ([`require-homepage-href-for-non-root-routes.yaml`](../k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml)). Use an existing tenant's `deploy/httproute.yaml` (e.g. `ascoachingogvaner`, which owns its hostnames *and* its dashboard annotations) as the reference. diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml new file mode 100644 index 0000000000..331fe193b2 --- /dev/null +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml @@ -0,0 +1,102 @@ +# Keeps every Homepage card pointing at the service's front page. +# +# Why: unless a route sets gethomepage.dev/href, Homepage builds the card link +# itself, from the route's first hostname plus the path of its FIRST rule's +# FIRST match (spec.rules[0].http.paths[0].path for an Ingress). Rule order is +# routing-neutral in the Gateway API, where the most specific match wins +# whatever its position, so nothing in a route reads as wrong when a narrow +# rule is listed first. Homepage is the only consumer of that order, and it +# fails silently: the card still renders, healthy, and links somewhere else. +# +# That is how the Coroot card broke. platform#5881 put an `Exact /mcp` rule +# (the agents' MCP endpoint, which bypasses SSO) ahead of the catch-all, so the +# card linked to /mcp, and a browser without a bearer token got Coroot's 401 +# "unauthorized" instead of the UI. A RegularExpression first path or a route +# without hostnames is worse: Homepage builds no link at all. +# +# So a route that opts into the dashboard may leave the link to Homepage only +# when the derived link is the site root; any other route must name its link +# explicitly with gethomepage.dev/href. The API server defaults an omitted rule +# list, match list or path to `PathPrefix /`, so an omitted one reads as the +# root here as well. +# +# Scope: every namespace, platform and tenant routes alike, with background +# scanning on so existing drift surfaces in the PolicyReports. CREATE and +# UPDATE only, never DELETE, so a route can always be removed (#4732). +apiVersion: policies.kyverno.io/v1 +kind: ValidatingPolicy +metadata: + name: require-homepage-href-for-non-root-routes + annotations: + policies.kyverno.io/title: Require Homepage Href For Non-Root Routes + policies.kyverno.io/category: Best Practices + policies.kyverno.io/severity: low + policies.kyverno.io/subject: HTTPRoute, Ingress + policies.kyverno.io/minversion: 1.17.0 + policies.kyverno.io/description: >- + Requires every route that opts into Homepage discovery + (gethomepage.dev/enabled: "true") to set gethomepage.dev/href unless the + link Homepage would derive from it is the site root, so reordering a + route's rules cannot silently move its dashboard card to a sub-path. +spec: + failurePolicy: Fail + validationActions: + - Deny + evaluation: + background: + enabled: true + matchConstraints: + resourceRules: + # HTTPRoute is what this platform uses. Ingress is Homepage's other + # discovery source and is included so a future Ingress cannot bypass the + # guard, the same scope as restrict-homepage-service-groups. + - apiGroups: ["gateway.networking.k8s.io"] + apiVersions: ["*"] + operations: ["CREATE", "UPDATE"] + resources: ["httproutes"] + - apiGroups: ["networking.k8s.io"] + apiVersions: ["*"] + operations: ["CREATE", "UPDATE"] + resources: ["ingresses"] + matchConditions: + - name: opted-into-homepage + expression: >- + object.metadata.?annotations.orValue({})[?'gethomepage.dev/enabled'].orValue('') == 'true' + variables: + # Homepage treats an empty href as absent, so this does too. + - name: href + expression: >- + object.metadata.?annotations.orValue({})[?'gethomepage.dev/href'].orValue('') + # https://, and no link at all + # without a hostname or for a RegularExpression path. + - name: httpRouteLinksToRoot + expression: >- + size(object.spec.?hostnames.orValue([])) > 0 && + ( + size(object.spec.?rules.orValue([])) == 0 || + size(object.spec.rules[0].?matches.orValue([])) == 0 || + ( + object.spec.rules[0].matches[0].?path.?value.orValue('/') == '/' && + object.spec.rules[0].matches[0].?path.?type.orValue('PathPrefix') != 'RegularExpression' + ) + ) + # ://; an Ingress has no + # defaulting here, so a missing host or path yields a broken link. + - name: ingressLinksToRoot + expression: >- + size(object.spec.?rules.orValue([])) > 0 && + object.spec.rules[0].?host.orValue('') != '' && + size(object.spec.rules[0].?http.?paths.orValue([])) > 0 && + object.spec.rules[0].http.paths[0].?path.orValue('') == '/' + validations: + - expression: >- + variables.href != '' || + (object.kind == 'HTTPRoute' ? variables.httpRouteLinksToRoot : variables.ingressLinksToRoot) + message: >- + A route with gethomepage.dev/enabled="true" must set + gethomepage.dev/href unless its first hostname and its first rule's + first path already form the site root. Homepage builds the card link + from exactly those two fields, so a route whose first rule matches a + sub-path (such as an /mcp or /api bypass) sends the card there, and a + RegularExpression path or a missing hostname leaves the card with no + link at all. diff --git a/k8s/bases/infrastructure/cluster-policies/kustomization.yaml b/k8s/bases/infrastructure/cluster-policies/kustomization.yaml index 849a69eac4..bad35e2b6b 100644 --- a/k8s/bases/infrastructure/cluster-policies/kustomization.yaml +++ b/k8s/bases/infrastructure/cluster-policies/kustomization.yaml @@ -18,6 +18,7 @@ resources: - best-practices/disable-default-sa-automount.yaml - best-practices/disallow-latest-tag.yaml - best-practices/propagate-reloader-to-flagger-primary.yaml + - best-practices/require-homepage-href-for-non-root-routes.yaml - best-practices/restrict-github-team-external-identity.yaml - best-practices/restrict-github-team-management.yaml - best-practices/restrict-arc-openbao-listener.yaml diff --git a/k8s/bases/infrastructure/controllers/coroot/http-route.yaml b/k8s/bases/infrastructure/controllers/coroot/http-route.yaml index 3f3a30a932..a432c398e0 100644 --- a/k8s/bases/infrastructure/controllers/coroot/http-route.yaml +++ b/k8s/bases/infrastructure/controllers/coroot/http-route.yaml @@ -31,6 +31,13 @@ metadata: # uses for the Cilium and OpenBao routes. Homepage fetches icons client-side, # so the cluster egress NetworkPolicy doesn't block it. gethomepage.dev/icon: https://raw.githubusercontent.com/coroot/coroot/main/front/public/icon.svg + # Card link. Without it Homepage builds the link from the hostname plus the + # FIRST rule's first path match, and the first rule below is the /mcp + # bypass, so the card opened Coroot's MCP endpoint, which answers a browser + # without a bearer token with 401 "unauthorized" (#5881 added that rule). + # Pin the UI root so rule order cannot move the link again; the + # require-homepage-href-for-non-root-routes policy enforces this. + gethomepage.dev/href: https://observability.${domain} spec: parentRefs: - name: platform diff --git a/tests/policy-failure-actions.json b/tests/policy-failure-actions.json index ed8f8186fe..32b35c3175 100644 --- a/tests/policy-failure-actions.json +++ b/tests/policy-failure-actions.json @@ -7,6 +7,7 @@ "audit-privileged-rbac": "Enforce", "disallow-latest-tag": "Audit", "restrict-homepage-service-groups": "Enforce", + "require-homepage-href-for-non-root-routes": "Enforce", "restrict-tenant-issuer-refs": "Enforce", "restrict-tenant-network-policies/cnp-ingress-sources-must-be-constrained": "Enforce", "restrict-tenant-network-policies/cnp-egress-destinations-must-be-constrained": "Enforce", diff --git a/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml b/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml new file mode 100644 index 0000000000..1b627e089d --- /dev/null +++ b/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml @@ -0,0 +1,60 @@ +apiVersion: cli.kyverno.io/v1alpha1 +kind: Test +metadata: + name: require-homepage-href-for-non-root-routes +policies: + - >- + ../../k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml +resources: + - resources.yaml + # Exercise the shipped Coroot route, whose /mcp rule comes first: it passes + # only while it keeps its explicit href. + - ../../k8s/bases/infrastructure/controllers/coroot/http-route.yaml +results: + # --- derived link is the site root, or an explicit href is set --- + - policy: require-homepage-href-for-non-root-routes + resources: + - whoami/root-prefix-first + - whoami/exact-root-first + - whoami/omitted-matches + - whoami/omitted-path + - observability/subpath-first-with-href + - observability/coroot + kind: HTTPRoute + result: pass + # --- a sub-path, a regex or no hostname, with no href --- + - policy: require-homepage-href-for-non-root-routes + resources: + - observability/subpath-first-without-href + - whoami/prefix-subpath-first + - whoami/root-is-second-match + - whoami/regex-root-first + - whoami/no-hostnames + - whoami/empty-href + kind: HTTPRoute + result: fail + # --- the precondition's negative control: routes that never opted in --- + - policy: require-homepage-href-for-non-root-routes + resources: + - whoami/not-opted-in + - whoami/discovery-disabled + kind: HTTPRoute + result: skip + # --- Ingress is guarded identically --- + - policy: require-homepage-href-for-non-root-routes + resources: + - whoami/ingress-root-path + - whoami/ingress-subpath-with-href + kind: Ingress + result: pass + - policy: require-homepage-href-for-non-root-routes + resources: + - whoami/ingress-subpath-without-href + - whoami/ingress-path-omitted + kind: Ingress + result: fail + - policy: require-homepage-href-for-non-root-routes + resources: + - whoami/ingress-not-opted-in + kind: Ingress + result: skip diff --git a/tests/require-homepage-href-for-non-root-routes/resources.yaml b/tests/require-homepage-href-for-non-root-routes/resources.yaml new file mode 100644 index 0000000000..03dcac9cd5 --- /dev/null +++ b/tests/require-homepage-href-for-non-root-routes/resources.yaml @@ -0,0 +1,372 @@ +# --- derived link is the site root, or an explicit href is set: pass --------- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: root-prefix-first + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: whoami + port: 80 + - matches: + - path: + type: Exact + value: /api + backendRefs: + - name: whoami-api + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: exact-root-first + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: Exact + value: / + backendRefs: + - name: whoami + port: 80 +--- +# The API server defaults an omitted match list to `PathPrefix /`; the CLI +# applies no defaults, so this is the pre-defaulting shape of a catch-all. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: omitted-matches + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + hostnames: + - whoami.platform.lan + rules: + - backendRefs: + - name: whoami + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: omitted-path + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + hostnames: + - whoami.platform.lan + rules: + - matches: + - headers: + - name: x-canary + value: "true" + backendRefs: + - name: whoami + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: subpath-first-with-href + namespace: observability + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/href: https://observability.platform.lan +spec: + hostnames: + - observability.platform.lan + rules: + - matches: + - path: + type: Exact + value: /mcp + backendRefs: + - name: coroot-mcp + port: 8080 + - backendRefs: + - name: oauth2-proxy + namespace: oauth2-proxy + port: 80 +--- +# --- derived link is a sub-path or missing, and no href: fail ---------------- +# The Coroot route as platform#5881 shipped it: the card linked to /mcp. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: subpath-first-without-href + namespace: observability + annotations: + gethomepage.dev/enabled: "true" +spec: + hostnames: + - observability.platform.lan + rules: + - matches: + - path: + type: Exact + value: /mcp + backendRefs: + - name: coroot-mcp + port: 8080 + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: oauth2-proxy + namespace: oauth2-proxy + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: prefix-subpath-first + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: PathPrefix + value: /api + backendRefs: + - name: whoami-api + port: 80 +--- +# A second match on the first rule does not count: Homepage reads matches[0]. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: root-is-second-match + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: PathPrefix + value: /docs + - path: + type: PathPrefix + value: / + backendRefs: + - name: whoami + port: 80 +--- +# Homepage builds no link for a RegularExpression path, even one matching "/". +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: regex-root-first + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: RegularExpression + value: / + backendRefs: + - name: whoami + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: no-hostnames + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: whoami + port: 80 +--- +# Homepage treats an empty href as unset and falls back to the derived link. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: empty-href + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/href: "" +spec: + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: PathPrefix + value: /api + backendRefs: + - name: whoami-api + port: 80 +--- +# --- the precondition's negative control: routes that never opted in -------- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: not-opted-in + namespace: whoami +spec: + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: Exact + value: /mcp + backendRefs: + - name: whoami + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: discovery-disabled + namespace: whoami + annotations: + gethomepage.dev/enabled: "false" +spec: + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: PathPrefix + value: /api/send + backendRefs: + - name: whoami + port: 80 +--- +# --- Ingress is guarded identically ------------------------------------------ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-root-path + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + rules: + - host: whoami.platform.lan + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: whoami + port: + number: 80 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-subpath-with-href + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/href: https://whoami.platform.lan +spec: + rules: + - host: whoami.platform.lan + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: whoami-api + port: + number: 80 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-subpath-without-href + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + rules: + - host: whoami.platform.lan + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: whoami-api + port: + number: 80 +--- +# ImplementationSpecific allows an omitted path; Homepage would append +# "undefined" to the host. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-path-omitted + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + rules: + - host: whoami.platform.lan + http: + paths: + - pathType: ImplementationSpecific + backend: + service: + name: whoami + port: + number: 80 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-not-opted-in + namespace: whoami +spec: + rules: + - host: whoami.platform.lan + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: whoami-api + port: + number: 80 From b3c7399ea4274ba7d1312224b3d73a1b3475fa1f Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 8 Nov 2026 13:16:24 +0100 Subject: [PATCH 2/4] fix(policies): scope the Homepage href guard to HTTPRoutes ksail workload validate skips any ValidatingPolicy whose spec strings match the CEL http library identifier, and the Ingress branch's `.?http.?paths` field access matched it. CI therefore warned that the policy was not evaluable offline and never evaluated it against the rendered routes, so a violating route would pass the pull request and only be denied during the merge-group deploy. Neither the repository nor the cluster serves an Ingress, and the tenant role grants only HTTPRoutes, so match HTTPRoutes alone and keep the offline evaluation. Co-Authored-By: Claude Opus 5.5 --- ...ire-homepage-href-for-non-root-routes.yaml | 48 ++++----- .../kyverno-test.yaml | 18 ---- .../resources.yaml | 101 ------------------ 3 files changed, 19 insertions(+), 148 deletions(-) diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml index 331fe193b2..108a6737c0 100644 --- a/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml @@ -2,11 +2,11 @@ # # Why: unless a route sets gethomepage.dev/href, Homepage builds the card link # itself, from the route's first hostname plus the path of its FIRST rule's -# FIRST match (spec.rules[0].http.paths[0].path for an Ingress). Rule order is -# routing-neutral in the Gateway API, where the most specific match wins -# whatever its position, so nothing in a route reads as wrong when a narrow -# rule is listed first. Homepage is the only consumer of that order, and it -# fails silently: the card still renders, healthy, and links somewhere else. +# FIRST match. Rule order is routing-neutral in the Gateway API, where the most +# specific match wins whatever its position, so nothing in a route reads as +# wrong when a narrow rule is listed first. Homepage is the only consumer of +# that order, and it fails silently: the card still renders, healthy, and links +# somewhere else. # # That is how the Coroot card broke. platform#5881 put an `Exact /mcp` rule # (the agents' MCP endpoint, which bypasses SSO) ahead of the catch-all, so the @@ -20,9 +20,15 @@ # list, match list or path to `PathPrefix /`, so an omitted one reads as the # root here as well. # -# Scope: every namespace, platform and tenant routes alike, with background -# scanning on so existing drift surfaces in the PolicyReports. CREATE and -# UPDATE only, never DELETE, so a route can always be removed (#4732). +# Scope: HTTPRoutes in every namespace, platform and tenant alike, with +# background scanning on so existing drift surfaces in the PolicyReports. +# CREATE and UPDATE only, never DELETE, so a route can always be removed +# (#4732). Ingress is deliberately not matched: neither this repository nor the +# cluster serves one, and the tenant role grants only HTTPRoutes. Reading an +# Ingress's link needs its `http` field, and `ksail workload validate` skips +# any policy whose expressions name `http` (it cannot tell the field from the +# CEL http library), so covering Ingress would cost the offline evaluation that +# lets a pull request fail on a violating route before admission does. apiVersion: policies.kyverno.io/v1 kind: ValidatingPolicy metadata: @@ -31,10 +37,10 @@ metadata: policies.kyverno.io/title: Require Homepage Href For Non-Root Routes policies.kyverno.io/category: Best Practices policies.kyverno.io/severity: low - policies.kyverno.io/subject: HTTPRoute, Ingress + policies.kyverno.io/subject: HTTPRoute policies.kyverno.io/minversion: 1.17.0 policies.kyverno.io/description: >- - Requires every route that opts into Homepage discovery + Requires every HTTPRoute that opts into Homepage discovery (gethomepage.dev/enabled: "true") to set gethomepage.dev/href unless the link Homepage would derive from it is the site root, so reordering a route's rules cannot silently move its dashboard card to a sub-path. @@ -47,17 +53,10 @@ spec: enabled: true matchConstraints: resourceRules: - # HTTPRoute is what this platform uses. Ingress is Homepage's other - # discovery source and is included so a future Ingress cannot bypass the - # guard, the same scope as restrict-homepage-service-groups. - apiGroups: ["gateway.networking.k8s.io"] apiVersions: ["*"] operations: ["CREATE", "UPDATE"] resources: ["httproutes"] - - apiGroups: ["networking.k8s.io"] - apiVersions: ["*"] - operations: ["CREATE", "UPDATE"] - resources: ["ingresses"] matchConditions: - name: opted-into-homepage expression: >- @@ -69,7 +68,7 @@ spec: object.metadata.?annotations.orValue({})[?'gethomepage.dev/href'].orValue('') # https://, and no link at all # without a hostname or for a RegularExpression path. - - name: httpRouteLinksToRoot + - name: linksToRoot expression: >- size(object.spec.?hostnames.orValue([])) > 0 && ( @@ -80,20 +79,11 @@ spec: object.spec.rules[0].matches[0].?path.?type.orValue('PathPrefix') != 'RegularExpression' ) ) - # ://; an Ingress has no - # defaulting here, so a missing host or path yields a broken link. - - name: ingressLinksToRoot - expression: >- - size(object.spec.?rules.orValue([])) > 0 && - object.spec.rules[0].?host.orValue('') != '' && - size(object.spec.rules[0].?http.?paths.orValue([])) > 0 && - object.spec.rules[0].http.paths[0].?path.orValue('') == '/' validations: - expression: >- - variables.href != '' || - (object.kind == 'HTTPRoute' ? variables.httpRouteLinksToRoot : variables.ingressLinksToRoot) + variables.href != '' || variables.linksToRoot message: >- - A route with gethomepage.dev/enabled="true" must set + An HTTPRoute with gethomepage.dev/enabled="true" must set gethomepage.dev/href unless its first hostname and its first rule's first path already form the site root. Homepage builds the card link from exactly those two fields, so a route whose first rule matches a diff --git a/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml b/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml index 1b627e089d..4e20c913bd 100644 --- a/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml +++ b/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml @@ -40,21 +40,3 @@ results: - whoami/discovery-disabled kind: HTTPRoute result: skip - # --- Ingress is guarded identically --- - - policy: require-homepage-href-for-non-root-routes - resources: - - whoami/ingress-root-path - - whoami/ingress-subpath-with-href - kind: Ingress - result: pass - - policy: require-homepage-href-for-non-root-routes - resources: - - whoami/ingress-subpath-without-href - - whoami/ingress-path-omitted - kind: Ingress - result: fail - - policy: require-homepage-href-for-non-root-routes - resources: - - whoami/ingress-not-opted-in - kind: Ingress - result: skip diff --git a/tests/require-homepage-href-for-non-root-routes/resources.yaml b/tests/require-homepage-href-for-non-root-routes/resources.yaml index 03dcac9cd5..21749e657d 100644 --- a/tests/require-homepage-href-for-non-root-routes/resources.yaml +++ b/tests/require-homepage-href-for-non-root-routes/resources.yaml @@ -269,104 +269,3 @@ spec: backendRefs: - name: whoami port: 80 ---- -# --- Ingress is guarded identically ------------------------------------------ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: ingress-root-path - namespace: whoami - annotations: - gethomepage.dev/enabled: "true" -spec: - rules: - - host: whoami.platform.lan - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: whoami - port: - number: 80 ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: ingress-subpath-with-href - namespace: whoami - annotations: - gethomepage.dev/enabled: "true" - gethomepage.dev/href: https://whoami.platform.lan -spec: - rules: - - host: whoami.platform.lan - http: - paths: - - path: /api - pathType: Prefix - backend: - service: - name: whoami-api - port: - number: 80 ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: ingress-subpath-without-href - namespace: whoami - annotations: - gethomepage.dev/enabled: "true" -spec: - rules: - - host: whoami.platform.lan - http: - paths: - - path: /api - pathType: Prefix - backend: - service: - name: whoami-api - port: - number: 80 ---- -# ImplementationSpecific allows an omitted path; Homepage would append -# "undefined" to the host. -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: ingress-path-omitted - namespace: whoami - annotations: - gethomepage.dev/enabled: "true" -spec: - rules: - - host: whoami.platform.lan - http: - paths: - - pathType: ImplementationSpecific - backend: - service: - name: whoami - port: - number: 80 ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: ingress-not-opted-in - namespace: whoami -spec: - rules: - - host: whoami.platform.lan - http: - paths: - - path: /api - pathType: Prefix - backend: - service: - name: whoami-api - port: - number: 80 From 98efcea1ea7d242e79ce014b409cb7fca533ed83 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 8 Nov 2026 13:42:00 +0100 Subject: [PATCH 3/4] fix(policies): require an href for wildcard hosts and empty rule lists Homepage derives no usable link from a wildcard first hostname (`*.example.com`), and it throws reading `[0]` of an explicitly empty rule or match list: the API server defaults only an omitted list, so an empty one stays empty. The guard read omitted and empty lists alike and accepted all three. Require gethomepage.dev/href for each, keep an omitted list reading as the defaulted root, and add fixtures for every case with and without an href. Co-Authored-By: Claude Opus 5.5 --- ...ire-homepage-href-for-non-root-routes.yaml | 26 +++-- .../kyverno-test.yaml | 8 +- .../resources.yaml | 100 ++++++++++++++++++ 3 files changed, 126 insertions(+), 8 deletions(-) diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml index 108a6737c0..d646b272af 100644 --- a/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml @@ -67,16 +67,27 @@ spec: expression: >- object.metadata.?annotations.orValue({})[?'gethomepage.dev/href'].orValue('') # https://, and no link at all - # without a hostname or for a RegularExpression path. + # without a hostname or for a RegularExpression path. A wildcard first + # hostname (`*.example.com`) yields a link no browser can open. The API + # server defaults only an OMITTED rule list or match list; an explicitly + # empty one stays empty, and Homepage then fails reading `[0]` of it, so + # omitted reads as the root and empty does not. - name: linksToRoot expression: >- size(object.spec.?hostnames.orValue([])) > 0 && + !object.spec.hostnames[0].startsWith('*') && ( - size(object.spec.?rules.orValue([])) == 0 || - size(object.spec.rules[0].?matches.orValue([])) == 0 || + !object.spec.?rules.hasValue() || ( - object.spec.rules[0].matches[0].?path.?value.orValue('/') == '/' && - object.spec.rules[0].matches[0].?path.?type.orValue('PathPrefix') != 'RegularExpression' + size(object.spec.rules) > 0 && + ( + !object.spec.rules[0].?matches.hasValue() || + ( + size(object.spec.rules[0].matches) > 0 && + object.spec.rules[0].matches[0].?path.?value.orValue('/') == '/' && + object.spec.rules[0].matches[0].?path.?type.orValue('PathPrefix') != 'RegularExpression' + ) + ) ) ) validations: @@ -88,5 +99,6 @@ spec: first path already form the site root. Homepage builds the card link from exactly those two fields, so a route whose first rule matches a sub-path (such as an /mcp or /api bypass) sends the card there, and a - RegularExpression path or a missing hostname leaves the card with no - link at all. + missing or wildcard hostname, a RegularExpression path, or an + explicitly empty rule or match list leaves the card with no usable + link. diff --git a/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml b/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml index 4e20c913bd..ca0ae8b33a 100644 --- a/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml +++ b/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml @@ -17,12 +17,15 @@ results: - whoami/root-prefix-first - whoami/exact-root-first - whoami/omitted-matches + - whoami/omitted-rules - whoami/omitted-path - observability/subpath-first-with-href + - whoami/wildcard-hostname-with-href + - whoami/empty-matches-with-href - observability/coroot kind: HTTPRoute result: pass - # --- a sub-path, a regex or no hostname, with no href --- + # --- a sub-path, a regex, a missing or wildcard hostname, or an empty list, with no href --- - policy: require-homepage-href-for-non-root-routes resources: - observability/subpath-first-without-href @@ -31,6 +34,9 @@ results: - whoami/regex-root-first - whoami/no-hostnames - whoami/empty-href + - whoami/wildcard-hostname + - whoami/empty-matches + - whoami/empty-rules kind: HTTPRoute result: fail # --- the precondition's negative control: routes that never opted in --- diff --git a/tests/require-homepage-href-for-non-root-routes/resources.yaml b/tests/require-homepage-href-for-non-root-routes/resources.yaml index 21749e657d..308fd5cd02 100644 --- a/tests/require-homepage-href-for-non-root-routes/resources.yaml +++ b/tests/require-homepage-href-for-non-root-routes/resources.yaml @@ -104,6 +104,56 @@ spec: namespace: oauth2-proxy port: 80 --- +# The API server defaults an omitted rule list to one `PathPrefix /` rule. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: omitted-rules + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + hostnames: + - whoami.platform.lan +--- +# The cases below would have no usable derived link, and an href fixes each. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: wildcard-hostname-with-href + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/href: https://whoami.platform.lan +spec: + hostnames: + - "*.platform.lan" + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: whoami + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: empty-matches-with-href + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/href: https://whoami.platform.lan +spec: + hostnames: + - whoami.platform.lan + rules: + - matches: [] + backendRefs: + - name: whoami + port: 80 +--- # --- derived link is a sub-path or missing, and no href: fail ---------------- # The Coroot route as platform#5881 shipped it: the card linked to /mcp. apiVersion: gateway.networking.k8s.io/v1 @@ -233,6 +283,56 @@ spec: - name: whoami-api port: 80 --- +# Homepage would link to https://*.platform.lan/, which no browser can open. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: wildcard-hostname + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + hostnames: + - "*.platform.lan" + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: whoami + port: 80 +--- +# An explicitly empty list is not defaulted: Homepage fails reading matches[0]. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: empty-matches + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + hostnames: + - whoami.platform.lan + rules: + - matches: [] + backendRefs: + - name: whoami + port: 80 +--- +# Likewise for an explicitly empty rule list: Homepage fails reading rules[0]. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: empty-rules + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + hostnames: + - whoami.platform.lan + rules: [] +--- # --- the precondition's negative control: routes that never opted in -------- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute From 6eae3bf01d8ddcff08520dfb46c85af8bd093683 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 8 Nov 2026 15:26:03 +0100 Subject: [PATCH 4/4] fix(policies): require an href for a Homepage route without a parentRef Homepage takes the card link's scheme from the Gateway named by spec.parentRefs[0], and reads parentRef.namespace before its lookup promise starts, so an omitted or empty parentRefs throws outside the lookup's error handler. The guard accepted such a route as a root link. Require gethomepage.dev/href for it, give every existing fixture route the platform gateway reference, and add fixtures for omitted and empty parentRefs with and without an href. Every live HTTPRoute already references the platform gateway. Co-Authored-By: Claude Opus 5.5 --- ...ire-homepage-href-for-non-root-routes.yaml | 17 ++- .../kyverno-test.yaml | 5 +- .../resources.yaml | 124 ++++++++++++++++++ 3 files changed, 138 insertions(+), 8 deletions(-) diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml index d646b272af..6d24314336 100644 --- a/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml @@ -68,12 +68,15 @@ spec: object.metadata.?annotations.orValue({})[?'gethomepage.dev/href'].orValue('') # https://, and no link at all # without a hostname or for a RegularExpression path. A wildcard first - # hostname (`*.example.com`) yields a link no browser can open. The API - # server defaults only an OMITTED rule list or match list; an explicitly - # empty one stays empty, and Homepage then fails reading `[0]` of it, so - # omitted reads as the root and empty does not. + # hostname (`*.example.com`) yields a link no browser can open. The scheme + # comes from the Gateway named by parentRefs[0], and Homepage throws reading + # that entry when parentRefs is omitted or empty. The API server defaults + # only an OMITTED rule list or match list; an explicitly empty one stays + # empty, and Homepage then fails reading `[0]` of it, so omitted reads as + # the root and empty does not. - name: linksToRoot expression: >- + size(object.spec.?parentRefs.orValue([])) > 0 && size(object.spec.?hostnames.orValue([])) > 0 && !object.spec.hostnames[0].startsWith('*') && ( @@ -99,6 +102,6 @@ spec: first path already form the site root. Homepage builds the card link from exactly those two fields, so a route whose first rule matches a sub-path (such as an /mcp or /api bypass) sends the card there, and a - missing or wildcard hostname, a RegularExpression path, or an - explicitly empty rule or match list leaves the card with no usable - link. + missing or wildcard hostname, a missing parentRef, a RegularExpression + path, or an explicitly empty rule or match list leaves the card with no + usable link. diff --git a/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml b/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml index ca0ae8b33a..f5e3188cd6 100644 --- a/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml +++ b/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml @@ -22,10 +22,11 @@ results: - observability/subpath-first-with-href - whoami/wildcard-hostname-with-href - whoami/empty-matches-with-href + - whoami/no-parent-refs-with-href - observability/coroot kind: HTTPRoute result: pass - # --- a sub-path, a regex, a missing or wildcard hostname, or an empty list, with no href --- + # --- a sub-path, a regex, a missing or wildcard hostname, no parentRef, or an empty list, with no href --- - policy: require-homepage-href-for-non-root-routes resources: - observability/subpath-first-without-href @@ -37,6 +38,8 @@ results: - whoami/wildcard-hostname - whoami/empty-matches - whoami/empty-rules + - whoami/no-parent-refs + - whoami/empty-parent-refs kind: HTTPRoute result: fail # --- the precondition's negative control: routes that never opted in --- diff --git a/tests/require-homepage-href-for-non-root-routes/resources.yaml b/tests/require-homepage-href-for-non-root-routes/resources.yaml index 308fd5cd02..24da563f27 100644 --- a/tests/require-homepage-href-for-non-root-routes/resources.yaml +++ b/tests/require-homepage-href-for-non-root-routes/resources.yaml @@ -7,6 +7,10 @@ metadata: annotations: gethomepage.dev/enabled: "true" spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - whoami.platform.lan rules: @@ -33,6 +37,10 @@ metadata: annotations: gethomepage.dev/enabled: "true" spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - whoami.platform.lan rules: @@ -54,6 +62,10 @@ metadata: annotations: gethomepage.dev/enabled: "true" spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - whoami.platform.lan rules: @@ -69,6 +81,10 @@ metadata: annotations: gethomepage.dev/enabled: "true" spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - whoami.platform.lan rules: @@ -89,6 +105,10 @@ metadata: gethomepage.dev/enabled: "true" gethomepage.dev/href: https://observability.platform.lan spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - observability.platform.lan rules: @@ -113,6 +133,10 @@ metadata: annotations: gethomepage.dev/enabled: "true" spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - whoami.platform.lan --- @@ -126,6 +150,10 @@ metadata: gethomepage.dev/enabled: "true" gethomepage.dev/href: https://whoami.platform.lan spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - "*.platform.lan" rules: @@ -146,6 +174,10 @@ metadata: gethomepage.dev/enabled: "true" gethomepage.dev/href: https://whoami.platform.lan spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - whoami.platform.lan rules: @@ -154,6 +186,22 @@ spec: - name: whoami port: 80 --- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: no-parent-refs-with-href + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/href: https://whoami.platform.lan +spec: + hostnames: + - whoami.platform.lan + rules: + - backendRefs: + - name: whoami + port: 80 +--- # --- derived link is a sub-path or missing, and no href: fail ---------------- # The Coroot route as platform#5881 shipped it: the card linked to /mcp. apiVersion: gateway.networking.k8s.io/v1 @@ -164,6 +212,10 @@ metadata: annotations: gethomepage.dev/enabled: "true" spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - observability.platform.lan rules: @@ -191,6 +243,10 @@ metadata: annotations: gethomepage.dev/enabled: "true" spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - whoami.platform.lan rules: @@ -211,6 +267,10 @@ metadata: annotations: gethomepage.dev/enabled: "true" spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - whoami.platform.lan rules: @@ -234,6 +294,10 @@ metadata: annotations: gethomepage.dev/enabled: "true" spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - whoami.platform.lan rules: @@ -253,6 +317,10 @@ metadata: annotations: gethomepage.dev/enabled: "true" spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https rules: - matches: - path: @@ -272,6 +340,10 @@ metadata: gethomepage.dev/enabled: "true" gethomepage.dev/href: "" spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - whoami.platform.lan rules: @@ -292,6 +364,10 @@ metadata: annotations: gethomepage.dev/enabled: "true" spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - "*.platform.lan" rules: @@ -312,6 +388,10 @@ metadata: annotations: gethomepage.dev/enabled: "true" spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - whoami.platform.lan rules: @@ -329,10 +409,46 @@ metadata: annotations: gethomepage.dev/enabled: "true" spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - whoami.platform.lan rules: [] --- +# Homepage reads parentRefs[0] for the scheme and throws when there is none. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: no-parent-refs + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + hostnames: + - whoami.platform.lan + rules: + - backendRefs: + - name: whoami + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: empty-parent-refs + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + parentRefs: [] + hostnames: + - whoami.platform.lan + rules: + - backendRefs: + - name: whoami + port: 80 +--- # --- the precondition's negative control: routes that never opted in -------- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute @@ -340,6 +456,10 @@ metadata: name: not-opted-in namespace: whoami spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - whoami.platform.lan rules: @@ -359,6 +479,10 @@ metadata: annotations: gethomepage.dev/enabled: "false" spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https hostnames: - whoami.platform.lan rules: