The Ingress Allowlisting Controller is a Kubernetes controller designed to manage allowlisting rules for Kubernetes Ingress resources. It ensures that only trusted IPs or ranges can access specific ingress endpoints, enhancing security and compliance. This k8s controller configures ingress allowlisting based on a custom CRD
To install the ingress-allowlisting-controller using the Helm chart provided in the repository, follow these steps:
- Clone the Repository Clone the repository to your local machine:
git clone https://github.com/adevinta/ingress-allowlisting-controller.git
cd ingress-allowlisting-controller/helm-charts/ingress-allowlisting-controller- Install the Chart Install the controller using the local Helm chart. Customize the installation by specifying the namespace and configuration if needed:
helm install ingress-allowlisting-controller ./ --namespace ingress-allowlisting --create-namespace- Verify the Installation Ensure the controller is running in your cluster:
kubectl get pods -n ingress-allowlistingYou should see a pod named ingress-allowlisting-controller running.
Once installed, the ingress-allowlisting-controller will monitor and apply allowlisting rules to Kubernetes Ingress resources.
Below are examples of an Ingress resource with allowlisting annotations, using both the namespace level CIDR CRD as well as the cluster level CRD
Namespaced version of CIDRs object
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
ipam.adevinta.com/allowlist-group: MyCidrsObjectCluster version of the CIDRs object
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
ipam.adevinta.com/cluster-allowlist-group: MyCidrsObjectThe content of the annotations can be a comma-separated list:
MyCidrsObject,MyCidrsObject2,MyCidrsObject3
The controller can watch Gateway resources and create an AuthorizationPolicy in the same namespace targeting the gateway itself.
Enable the feature via Helm:
gateway:
enabled: trueOr directly with the flag: --gateway-support-enabled.
Required annotations:
ipam.adevinta.com/allowlist-groupand/oripam.adevinta.com/cluster-allowlist-group- same as for Ingress
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: my-gateway
namespace: my-app
annotations:
ipam.adevinta.com/cluster-allowlist-group: office-ipsThe controller generates in my-app:
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: my-gateway # same as the Gateway name
namespace: my-app
spec:
action: ALLOW
rules:
- from:
- source:
remoteIpBlocks:
- 10.0.0.0/8
- 192.168.0.0/16
targetRef:
group: gateway.networking.k8s.io
kind: Gateway
name: my-gatewayThe AuthorizationPolicy is created in the same namespace as the Gateway and uses targetRef (singular) pointing directly at the gateway - no cross-namespace support, no hostnames, no merge mode. The AP is owned by the Gateway resource and garbage-collected automatically when the Gateway is deleted.
The controller can also watch HTTPRoute resources and create an AuthorizationPolicy targeting the associated Istio Gateway.
Enable the feature via Helm:
httproute:
enabled: trueOr directly with the flag: --httproute-support-enabled.
Required annotations:
ipam.adevinta.com/allowlist-groupand/oripam.adevinta.com/cluster-allowlist-group- same as for Ingress
The gateway name, gateway namespace, and target namespace for the AuthorizationPolicy are all derived automatically from spec.parentRefs - no extra annotations needed.
Same-namespace case - parentRef has no namespace, so the AuthorizationPolicy is created in the same namespace as the HTTPRoute:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-route
namespace: my-app
annotations:
ipam.adevinta.com/cluster-allowlist-group: office-ips
spec:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: my-gateway
hostnames:
- app.example.comThe controller generates in my-app:
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: my-gateway-my-route # {gateway.Name}-{httproute.Name}
namespace: my-app
ownerReferences: # automatically garbage-collected when the HTTPRoute is deleted
- kind: HTTPRoute
name: my-route
spec:
action: ALLOW
rules:
- from:
- source:
remoteIpBlocks:
- 10.0.0.0/8
- 192.168.0.0/16
to:
- operation:
hosts:
- app.example.com
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: my-gatewayCross-namespace case - parentRef has a namespace that differs from the HTTPRoute namespace. The AuthorizationPolicy is created in the gateway namespace:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-route
namespace: my-app
annotations:
ipam.adevinta.com/cluster-allowlist-group: office-ips
spec:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cross-namespace-gateway
namespace: infra # different from HTTPRoute namespace
hostnames:
- app.example.comThe controller generates in infra:
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: cross-namespace-gateway-my-app-my-route # {gateway.Name}-{httproute.Namespace}-{httproute.Name}
namespace: infra
# no ownerReference - cross-namespace owner references are not supported by Kubernetes
spec:
action: ALLOW
rules:
- from:
- source:
remoteIpBlocks:
- 10.0.0.0/8
- 192.168.0.0/16
to:
- operation:
hosts:
- app.example.com
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cross-namespace-gatewayNote: Cross-namespace
AuthorizationPolicyresources are not garbage-collected automatically when theHTTPRouteis deleted. Kubernetes does not support cross-namespace owner references, so the controller cannot set one - the API server would silently strip it anyway. This means stale APs can accumulate when routes are deleted, have theirparentRefschanged, or switch between normal and merge mode.Automatic cleanup on restart: every time the controller starts, it runs a one-time sweep over all
AuthorizationPolicyresources it owns (identified by theapp.kubernetes.io/managed-by=ingress-allowlisting-controllerlabel). Any AP whose ownerHTTPRouteno longer exists, or that the current route configuration would no longer produce, is deleted. Restarting the controller is therefore sufficient to clean up any accumulated orphans.Hard reset: to remove all APs managed by this controller and let it rebuild from scratch:
kubectl delete authorizationpolicies -A -l app.kubernetes.io/managed-by=ingress-allowlisting-controller # then restart the controller kubectl rollout restart deployment/ingress-allowlisting-controller -n <namespace>
Multiple gateways - an HTTPRoute can reference more than one gateway. The controller creates one AuthorizationPolicy per gateway, each prefixed with the gateway name:
spec:
parentRefs:
- name: internal-gateway # → AuthorizationPolicy: internal-gateway-{httproute.Name}
- name: external-gateway # → AuthorizationPolicy: external-gateway-{httproute.Name}Cross-namespace refs follow the same prefix convention: {gateway.Name}-{httproute.Namespace}-{httproute.Name}.
Performance tuning - if you have thousands of HTTPRoutes in a cluster, you can restrict the informer cache to only the ones opted into allowlisting using a label selector:
httproute:
enabled: true
labelSelector: "ipam.adevinta.com/allowlisting=enabled"Or via flag: --httproute-label-selector=ipam.adevinta.com/allowlisting=enabled.
This filters at the API server level, reducing both memory usage and reconcile load.
Merge mode - for staging environments where the same application is deployed across multiple namespaces (e.g. ns-staging00, ns-staging01, ...) and all point to a shared cross-namespace gateway, you can set ipam.adevinta.com/merge to a shared policy name to produce a single AuthorizationPolicy that covers all of them.
The annotation value is both the merge key (which routes belong together) and the name of the generated AuthorizationPolicy. HTTPRoute names can be anything - typically they are hostname-based - and do not need to match.
# In namespace ns-staging00:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: chaos-monkey.public.ns-staging00.example.com
namespace: ns-staging00
annotations:
ipam.adevinta.com/allowlist-group: allowlist
ipam.adevinta.com/merge: chaos-monkey # merge key = AP name
spec:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cross-namespace-public
namespace: ns-infra
hostnames:
- chaos-monkey.public.ns-staging00.example.com
---
# In namespace ns-staging01:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: chaos-monkey.public.ns-staging01.example.com
namespace: ns-staging01
annotations:
ipam.adevinta.com/allowlist-group: allowlist
ipam.adevinta.com/merge: chaos-monkey # same merge key → same AP
spec:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cross-namespace-public
namespace: ns-infra
hostnames:
- chaos-monkey.public.ns-staging01.example.comThe controller generates a single AuthorizationPolicy in ns-infra. Routes with the same CIDR set are compacted into one rule; routes with different CIDR sets each get their own rule — this preserves the security boundary (a CIDR allowed for one hostname cannot implicitly reach another):
# Example A: both routes share the same CIDR set (e.g. same allowlist object)
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: chaos-monkey # = the merge key
namespace: ns-infra
spec:
action: ALLOW
rules:
- from:
- source:
remoteIpBlocks:
- 1.2.3.4/32 # shared CIDRs → one rule
to:
- operation:
hosts:
- chaos-monkey.public.ns-staging00.example.com
- operation:
hosts:
- chaos-monkey.public.ns-staging01.example.com
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cross-namespace-public
---
# Example B: each route has a different CIDR set → one rule per CIDR set
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: chaos-monkey
namespace: ns-infra
spec:
action: ALLOW
rules:
- from:
- source:
remoteIpBlocks:
- 1.2.3.4/32 # only ns-staging00's CIDRs
to:
- operation:
hosts:
- chaos-monkey.public.ns-staging00.example.com
- from:
- source:
remoteIpBlocks:
- 5.6.7.8/32 # only ns-staging01's CIDRs
to:
- operation:
hosts:
- chaos-monkey.public.ns-staging01.example.com
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cross-namespace-publicMerge rules:
- All HTTPRoutes with the same annotation value and the same target gateway are merged together, regardless of their
metadata.name. - Routes with the same sorted CIDR set share one
fromblock (compacted for readability). Routes with different CIDR sets each get their ownrule— security isolation is preserved. - Within a CIDR group, routes with the same hostname set share one
toblock, with all their paths collected together. - Reconciling any one sibling rebuilds the full merged policy.
Granularity - by default the controller creates one AuthorizationPolicy per HTTPRoute rule (one per path set). Use the ipam.adevinta.com/granularity annotation to change this behaviour:
| Value | AP count | AP name | to block |
|---|---|---|---|
absent / rule |
one per rule | {gw}-{route}-{path} |
hosts + path from that rule |
host |
one per route | {gw}-{route} |
hosts only, no path restriction |
# granularity=rule (default): separate AP per path
metadata:
annotations:
ipam.adevinta.com/granularity: rule # or omit entirely
spec:
rules:
- matches:
- path:
value: /api # → AP named {gw}-{route}-api
- matches:
- path:
value: /health # → AP named {gw}-{route}-health
---
# granularity=host: one AP for the whole route, no path restriction
metadata:
annotations:
ipam.adevinta.com/granularity: hostgranularity also applies inside merge mode: when a sibling has granularity=host, its rule contributes to the merged AP without any path restriction; when it has granularity=rule (default), each rule's paths are included.
Security warning: Merge mode is not recommended for production environments. Any namespace in the cluster that sets the same merge key and points to the same gateway will be pulled into the same
AuthorizationPolicy. This means a team controlling a different namespace could add their application's hostnames and CIDRs to your policy, potentially opening access to their service through your allowlist - or having their service inadvertently protected by your rules.Merge mode is designed for staging environments where multiple namespace-isolated instances of the same application exist under the same team's control and share a single gateway.
Below are examples of NetworkPolicy resources with different policyTypes (Ingress or Egress).
NetworkPolicy with ingress with namespaced version of CIDRs object
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: ingress-allow-myips
annotations:
ipam.adevinta.com/allowlist-group: MyCidrsObject
spec:
podSelector: {} # Applies to all pods in namespace
policyTypes:
- Ingress
# Controller will populate spec.ingress[0] with ipBlock rules hereNetworkPolicy with egress using a cluster-scoped CIDRs object and predefined ports:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: egress-allow-http
annotations:
ipam.adevinta.com/cluster-allowlist-group: http-allowed
spec:
podSelector: {} # Applies to all pods in namespace
policyTypes:
- Egress
# Controller will populate spec.egress[0] with ipBlock rules here respecting the ports
egress:
- ports:
- port: 443
- port: 80- You define:
podSelectorandpolicyTypes - Controller manages: Automatically populates and maintains the
spec.ingressorspec.egresssections withipBlockrules - Overwrite behavior: Any existing
ipBlockrules in these sections will be overwritten by the controller - Dual policyTypes: If both
IngressandEgressare specified, the controller will populate both sections
spec:
ingress: # For Ingress policies
- from:
- ipBlock:
cidr: x.x.x.x/y
egress: # For Egress policies
- to:
- ipBlock:
cidr: x.x.x.x/yapiVersion: ipam.adevinta.com/v1alpha1
kind: CIDRs
metadata:
name: MyCidrsObject
spec:
cidrs:
- 1.1.1.1/32
- 2.2.2.2/32apiVersion: ipam.adevinta.com/v1alpha1
kind: ClusterCIDRs
metadata:
name: Cloudfront
spec:
cidrs:
- 120.52.22.96/27
- 205.251.249.0/24
- 180.163.57.128/26Ingress-allowlister supports synchronizing CIDRs from remote http sources. To use this feature, configure the CIDRs or ClusterCIDRs object as follows
apiVersion: ipam.adevinta.com/v1alpha1
kind: CIDRs
metadata:
name: ec2
namespace: test
spec:
requeueAfter: 30m # Re-evaluate the remote URL every 30 minutes
location:
cel: 'data.prefixes.filter(p, p.service == "EC2" && has(p.ip_prefix)).map(p, p.ip_prefix)' # transform the AWS response into a list of strings using CEL expression
uri: https://ip-ranges.amazonaws.com/ip-ranges.json # the remote URL responding all IPs
headersFrom: # optional: inject CIDRs to the HTTP request (if the request needs to be authenticated)
secretRef: # optional: inject all keys
name: aws-authentication-headers # all aws-authentication-headers data will be used as http headers in the http request
namespace: test # optional. For CIDRs, it must match the CIDRs namespace when not empty.
configMapRef:
name: aws-headers # all aws-headers data will be used as http headers in the http request
namespace: test # optional. For CIDRs, it must match the CIDRs namespace when not empty.
---
# optional
apiVersion: v1
kind: ConfigMap
metadata:
name: aws-headers
namespace: test
data:
My-Header: some-value
---
# optional
apiVersion: v1
kind: Secret
metadata:
name: aws-authentication-headers
namespace: test
data:
Authentication: $(echo "Bearer $token" | base64)To fetch CIDRs stored in github repositories, you can use the github API endpoint:
apiVersion: ipam.adevinta.com/v1alpha1
kind: CIDRs
metadata:
name: my-cidrs
namespace: test
spec:
requeueAfter: 30m
location:
uri: https://api.github.com/repos/my-org/my-repo/contents/path/to/cidrs/file.jsonThe Gateway controller creates an AuthorizationPolicy with action: ALLOW targeting the Gateway directly - this is L4-level protection (IP-only, no hostname matching).
The HTTPRoute controller creates AuthorizationPolicy resources with action: ALLOW targeting the same Gateway but scoped per-hostname - this is L7-level protection.
Istio evaluates multiple ALLOW policies with OR logic. A request is allowed if it matches ANY ALLOW policy targeting that resource. This means:
Request from 5.5.5.5 → app.example.com
Gateway AP: ALLOW from [10.0.0.0/8] → ✗ no match
HTTPRoute AP: ALLOW from [5.5.5.5/32] host app.example.com → ✓ match → ALLOWED
The HTTPRoute AP bypasses the Gateway AP entirely. Do not use both allowlist-group on a Gateway and on its HTTPRoutes simultaneously. Pick one level:
- Use Gateway-level when you want a single uniform allowlist for all traffic through the gateway, regardless of hostname.
- Use HTTPRoute-level when you need per-route control with different CIDRs per service.
When using HTTPRoute-level allowlisting on a cross-namespace gateway, each HTTPRoute produces an ALLOW policy scoped to its hostnames. However, any hostname not covered by an ALLOW policy defaults to Istio's implicit deny - unless another policy creates a gap.
To explicitly lock down which hostnames are permitted to reach the gateway at all, add a DENY policy that blocks anything not matching your expected hostname patterns:
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: protect-gateway-hostnames
namespace: infra
spec:
action: DENY
rules:
- to:
- operation:
notHosts:
- '*.public.ns-staging00.example.com'
- '*.public.ns-staging01.example.com'
- '*.public.ns-staging02.example.com'
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cross-namespace-gatewayDENY rules take precedence over all ALLOW rules in Istio - this ensures that even if an ALLOW policy is misconfigured or overly broad, traffic to unexpected hostnames is blocked at the gateway level.
A cross-namespace Gateway accepting routes from arbitrary namespaces is a lateral movement risk - any team that can create an HTTPRoute can attach to your gateway. Restrict attachment using the Gateway's allowedRoutes listener configuration:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: cross-namespace-gateway
namespace: infra
spec:
gatewayClassName: istio
listeners:
- name: http
port: 80
protocol: HTTP
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
cross-gateway-access: "true"Only namespaces with the label cross-gateway-access: "true" can attach HTTPRoutes to this gateway. All other HTTPRoutes are rejected by the Gateway controller (Accepted=False) and no traffic flows for them - regardless of any AuthorizationPolicy that may exist.
Combine all three layers for defence in depth:
- Namespace label selector on the Gateway listener - controls who can attach
- HTTPRoute-level ALLOW policies via this controller - controls which IPs can reach each service
- Gateway-level DENY policy on unexpected hostnames - prevents gaps from misconfigured or missing ALLOW policies
AuthorizationPolicy rules match on remoteIpBlocks — this requires the original client IP to reach the Istio proxy. On AWS, load balancers terminate connections and replace the source IP with the LB's own address unless explicitly configured to propagate it.
Without this configuration, all requests appear to come from the load balancer's IP and no allowlist rule will match correctly — effectively making the allowlist useless.
Solution: Proxy Protocol
Use Proxy Protocol to propagate the client IP through the AWS NLB to the Istio gateway:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: my-gateway
annotations:
# Tell AWS LB to send Proxy Protocol v2 headers
service.beta.kubernetes.io/aws-load-balancer-proxy-protocol: '*'
# Tell Istio's Envoy proxy to expect and parse Proxy Protocol
proxy.istio.io/config: '{"gatewayTopology": {"proxyProtocol": {}}}'Both annotations are required — the first enables Proxy Protocol on the AWS side, the second tells Envoy to parse the protocol header and extract the real client IP for use in remoteIpBlocks matching.
The operator exposes a single metric namespace_ingress_IpAllowlistingGroup_missing that, when operated appropiately, it offer several information:
# HELP namespace_ingress_IpAllowlistingGroup_missing Number of missing IpAllowlistingGroup objects. >0 implies expected objects were not found
# TYPE namespace_ingress_IpAllowlistingGroup_missing gauge
namespace_ingress_IpAllowlistingGroup_missing{cidrs_name="alvarocidr",ingress="kube-nurse-kubenurse",namespace="cre-system"} 0
When the metric exists and equals 0, it means that there are no errors; the given object in the given namespace associated to the given ingress exists and has been resolved adequately.
When the metric exists and equals 1 means that there was an error resolving the cidr_name, probably, because the object didn't exist in the namespace.
count(sum(namespace_ingress_IpAllowlistingGroup_missing) by (ingress))
count(sum(namespace_ingress_IpAllowlistingGroup_missing) by (ingress) > 0)