From 312ebb17ab7ce6a2ee2d384273896d6e467f5962 Mon Sep 17 00:00:00 2001 From: Michal Kralik Date: Tue, 24 Sep 2024 18:04:05 +0200 Subject: [PATCH] Add support for ipv6 subnet in ipStrategy --- docs/content/middlewares/http/inflightreq.md | 62 ++++++++++++- docs/content/middlewares/http/ipallowlist.md | 60 +++++++++++++ docs/content/middlewares/http/ipwhitelist.md | 60 +++++++++++++ docs/content/middlewares/http/ratelimit.md | 62 ++++++++++++- .../dynamic-configuration/docker-labels.yml | 4 + .../reference/dynamic-configuration/file.toml | 4 + .../reference/dynamic-configuration/file.yaml | 4 + .../kubernetes-crd-definition-v1.yml | 22 +++++ .../reference/dynamic-configuration/kv-ref.md | 4 + .../traefik.io_middlewares.yaml | 22 +++++ integration/fixtures/k8s/01-traefik-crd.yml | 22 +++++ pkg/config/dynamic/middleware_test.go | 57 ++++++++++++ pkg/config/dynamic/middlewares.go | 18 +++- pkg/config/dynamic/zz_generated.deepcopy.go | 5 ++ pkg/config/label/label_test.go | 16 ++++ pkg/ip/strategy.go | 44 ++++++++- pkg/ip/strategy_test.go | 90 +++++++++++++++++-- 17 files changed, 544 insertions(+), 12 deletions(-) create mode 100644 pkg/config/dynamic/middleware_test.go diff --git a/docs/content/middlewares/http/inflightreq.md b/docs/content/middlewares/http/inflightreq.md index 16809ac80..48200fd90 100644 --- a/docs/content/middlewares/http/inflightreq.md +++ b/docs/content/middlewares/http/inflightreq.md @@ -101,7 +101,7 @@ If none are set, the default is to use the `requestHost`. #### `sourceCriterion.ipStrategy` -The `ipStrategy` option defines two parameters that configures how Traefik determines the client IP: `depth`, and `excludedIPs`. +The `ipStrategy` option defines three parameters that configures how Traefik determines the client IP: `depth`, `excludedIPs` and `ipv6Subnet`. !!! important "As a middleware, InFlightReq happens before the actual proxying to the backend takes place. In addition, the previous network hop only gets appended to `X-Forwarded-For` during the last stages of proxying, i.e. after it has already passed through the middleware. Therefore, during InFlightReq, as the previous network hop is not yet present in `X-Forwarded-For`, it cannot be used and/or relied upon." @@ -112,6 +112,9 @@ The `depth` option tells Traefik to use the `X-Forwarded-For` header and select - If `depth` is greater than the total number of IPs in `X-Forwarded-For`, then the client IP is empty. - `depth` is ignored if its value is less than or equal to 0. +If `ipStrategy.ipv6Subnet` is provided and the selected IP is IPv6, the IP is transformed into the first IP of the subnet it belongs to. +See [ipStrategy.ipv6Subnet](#ipstrategyipv6subnet) for more details. + !!! example "Example of Depth & X-Forwarded-For" If `depth` is set to 2, and the request `X-Forwarded-For` header is `"10.0.0.1,11.0.0.1,12.0.0.1,13.0.0.1"` then the "real" client IP is `"10.0.0.1"` (at depth 4) but the IP used as the criterion is `"12.0.0.1"` (`depth=2`). @@ -218,6 +221,63 @@ http: excludedIPs = ["127.0.0.1/32", "192.168.1.7"] ``` +##### `ipStrategy.ipv6Subnet` + +This strategy applies to `Depth` and `RemoteAddr` strategy only. +If `ipv6Subnet` is provided and the selected IP is IPv6, the IP is transformed into the first IP of the subnet it belongs to. + +This is useful for grouping IPv6 addresses into subnets to prevent bypassing this middleware by obtaining a new IPv6. + +- `ipv6Subnet` is ignored if its value is outside of 0-128 interval + +!!! example "Example of ipv6Subnet" + + If `ipv6Subnet` is provided, the IP is transformed in the following way. + + | `IP` | `ipv6Subnet` | clientIP | + |---------------------------|--------------|-----------------------| + | `"::abcd:1111:2222:3333"` | `64` | `"::0:0:0:0"` | + | `"::abcd:1111:2222:3333"` | `80` | `"::abcd:0:0:0:0"` | + | `"::abcd:1111:2222:3333"` | `96` | `"::abcd:1111:0:0:0"` | + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-inflightreq.inflightreq.sourcecriterion.ipstrategy.ipv6Subnet=64" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-inflightreq +spec: + inFlightReq: + sourceCriterion: + ipStrategy: + ipv6Subnet: 64 +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-inflightreq.inflightreq.sourcecriterion.ipstrategy.ipv6Subnet=64" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-inflightreq: + inFlightReq: + sourceCriterion: + ipStrategy: + ipv6Subnet: 64 +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-inflightreq.inflightreq] + [http.middlewares.test-inflightreq.inFlightReq.sourceCriterion.ipStrategy] + ipv6Subnet = 64 +``` + #### `sourceCriterion.requestHeaderName` Name of the header used to group incoming requests. diff --git a/docs/content/middlewares/http/ipallowlist.md b/docs/content/middlewares/http/ipallowlist.md index 47c3b0caf..4f9331268 100644 --- a/docs/content/middlewares/http/ipallowlist.md +++ b/docs/content/middlewares/http/ipallowlist.md @@ -75,6 +75,9 @@ The `depth` option tells Traefik to use the `X-Forwarded-For` header and take th - If `depth` is greater than the total number of IPs in `X-Forwarded-For`, then the client IP will be empty. - `depth` is ignored if its value is less than or equal to 0. +If `ipStrategy.ipv6Subnet` is provided and the selected IP is IPv6, the IP is transformed into the first IP of the subnet it belongs to. +See [ipStrategy.ipv6Subnet](#ipstrategyipv6subnet) for more details. + !!! example "Examples of Depth & X-Forwarded-For" If `depth` is set to 2, and the request `X-Forwarded-For` header is `"10.0.0.1,11.0.0.1,12.0.0.1,13.0.0.1"` then the "real" client IP is `"10.0.0.1"` (at depth 4) but the IP used is `"12.0.0.1"` (`depth=2`). @@ -204,3 +207,60 @@ http: [http.middlewares.test-ipallowlist.ipAllowList.ipStrategy] excludedIPs = ["127.0.0.1/32", "192.168.1.7"] ``` + +#### `ipStrategy.ipv6Subnet` + +This strategy applies to `Depth` and `RemoteAddr` strategy only. +If `ipv6Subnet` is provided and the selected IP is IPv6, the IP is transformed into the first IP of the subnet it belongs to. + +This is useful for grouping IPv6 addresses into subnets to prevent bypassing this middleware by obtaining a new IPv6. + +- `ipv6Subnet` is ignored if its value is outside of 0-128 interval + +!!! example "Example of ipv6Subnet" + + If `ipv6Subnet` is provided, the IP is transformed in the following way. + + | `IP` | `ipv6Subnet` | clientIP | + |---------------------------|--------------|-----------------------| + | `"::abcd:1111:2222:3333"` | `64` | `"::0:0:0:0"` | + | `"::abcd:1111:2222:3333"` | `80` | `"::abcd:0:0:0:0"` | + | `"::abcd:1111:2222:3333"` | `96` | `"::abcd:1111:0:0:0"` | + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ipallowlist.ipallowlist.sourcecriterion.ipstrategy.ipv6Subnet=64" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ipallowlist +spec: + ipallowlist: + sourceCriterion: + ipStrategy: + ipv6Subnet: 64 +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ipallowlist.ipallowlist.sourcecriterion.ipstrategy.ipv6Subnet=64" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ipallowlist: + ipallowlist: + sourceCriterion: + ipStrategy: + ipv6Subnet: 64 +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ipallowlist.ipallowlist] + [http.middlewares.test-ipallowlist.ipallowlist.sourceCriterion.ipStrategy] + ipv6Subnet = 64 +``` diff --git a/docs/content/middlewares/http/ipwhitelist.md b/docs/content/middlewares/http/ipwhitelist.md index f25960a52..b6aa60136 100644 --- a/docs/content/middlewares/http/ipwhitelist.md +++ b/docs/content/middlewares/http/ipwhitelist.md @@ -81,6 +81,9 @@ The `depth` option tells Traefik to use the `X-Forwarded-For` header and take th - If `depth` is greater than the total number of IPs in `X-Forwarded-For`, then the client IP will be empty. - `depth` is ignored if its value is less than or equal to 0. +If `ipStrategy.ipv6Subnet` is provided and the selected IP is IPv6, the IP is transformed into the first IP of the subnet it belongs to. +See [ipStrategy.ipv6Subnet](#ipstrategyipv6subnet) for more details. + !!! example "Examples of Depth & X-Forwarded-For" If `depth` is set to 2, and the request `X-Forwarded-For` header is `"10.0.0.1,11.0.0.1,12.0.0.1,13.0.0.1"` then the "real" client IP is `"10.0.0.1"` (at depth 4) but the IP used for the whitelisting is `"12.0.0.1"` (`depth=2`). @@ -210,3 +213,60 @@ http: [http.middlewares.test-ipwhitelist.ipWhiteList.ipStrategy] excludedIPs = ["127.0.0.1/32", "192.168.1.7"] ``` + +#### `ipStrategy.ipv6Subnet` + +This strategy applies to `Depth` and `RemoteAddr` strategy only. +If `ipv6Subnet` is provided and the selected IP is IPv6, the IP is transformed into the first IP of the subnet it belongs to. + +This is useful for grouping IPv6 addresses into subnets to prevent bypassing this middleware by obtaining a new IPv6. + +- `ipv6Subnet` is ignored if its value is outside of 0-128 interval + +!!! example "Example of ipv6Subnet" + + If `ipv6Subnet` is provided, the IP is transformed in the following way. + + | `IP` | `ipv6Subnet` | clientIP | + |---------------------------|--------------|-----------------------| + | `"::abcd:1111:2222:3333"` | `64` | `"::0:0:0:0"` | + | `"::abcd:1111:2222:3333"` | `80` | `"::abcd:0:0:0:0"` | + | `"::abcd:1111:2222:3333"` | `96` | `"::abcd:1111:0:0:0"` | + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ipWhiteList.ipWhiteList.sourcecriterion.ipstrategy.ipv6Subnet=64" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ipWhiteList +spec: + ipWhiteList: + sourceCriterion: + ipStrategy: + ipv6Subnet: 64 +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ipWhiteList.ipWhiteList.sourcecriterion.ipstrategy.ipv6Subnet=64" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ipWhiteList: + ipWhiteList: + sourceCriterion: + ipStrategy: + ipv6Subnet: 64 +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ipWhiteList.ipWhiteList] + [http.middlewares.test-ipWhiteList.ipWhiteList.sourceCriterion.ipStrategy] + ipv6Subnet = 64 +``` diff --git a/docs/content/middlewares/http/ratelimit.md b/docs/content/middlewares/http/ratelimit.md index 39a1f464f..dc03dc9a3 100644 --- a/docs/content/middlewares/http/ratelimit.md +++ b/docs/content/middlewares/http/ratelimit.md @@ -211,7 +211,7 @@ If none are set, the default is to use the request's remote address field (as an #### `sourceCriterion.ipStrategy` -The `ipStrategy` option defines two parameters that configures how Traefik determines the client IP: `depth`, and `excludedIPs`. +The `ipStrategy` option defines three parameters that configures how Traefik determines the client IP: `depth`, `excludedIPs` and `ipv6Subnet`. !!! important "As a middleware, rate-limiting happens before the actual proxying to the backend takes place. In addition, the previous network hop only gets appended to `X-Forwarded-For` during the last stages of proxying, i.e. after it has already passed through rate-limiting. Therefore, during rate-limiting, as the previous network hop is not yet present in `X-Forwarded-For`, it cannot be found and/or relied upon." @@ -222,6 +222,9 @@ The `depth` option tells Traefik to use the `X-Forwarded-For` header and select - If `depth` is greater than the total number of IPs in `X-Forwarded-For`, then the client IP is empty. - `depth` is ignored if its value is less than or equal to 0. +If `ipStrategy.ipv6Subnet` is provided and the selected IP is IPv6, the IP is transformed into the first IP of the subnet it belongs to. +See [ipStrategy.ipv6Subnet](#ipstrategyipv6subnet) for more details. + !!! example "Example of Depth & X-Forwarded-For" If `depth` is set to 2, and the request `X-Forwarded-For` header is `"10.0.0.1,11.0.0.1,12.0.0.1,13.0.0.1"` then the "real" client IP is `"10.0.0.1"` (at depth 4) but the IP used as the criterion is `"12.0.0.1"` (`depth=2`). @@ -355,6 +358,63 @@ http: excludedIPs = ["127.0.0.1/32", "192.168.1.7"] ``` +##### `ipStrategy.ipv6Subnet` + +This strategy applies to `Depth` and `RemoteAddr` strategy only. +If `ipv6Subnet` is provided and the selected IP is IPv6, the IP is transformed into the first IP of the subnet it belongs to. + +This is useful for grouping IPv6 addresses into subnets to prevent bypassing this middleware by obtaining a new IPv6. + +- `ipv6Subnet` is ignored if its value is outside of 0-128 interval + +!!! example "Example of ipv6Subnet" + + If `ipv6Subnet` is provided, the IP is transformed in the following way. + + | `IP` | `ipv6Subnet` | clientIP | + |---------------------------|--------------|-----------------------| + | `"::abcd:1111:2222:3333"` | `64` | `"::0:0:0:0"` | + | `"::abcd:1111:2222:3333"` | `80` | `"::abcd:0:0:0:0"` | + | `"::abcd:1111:2222:3333"` | `96` | `"::abcd:1111:0:0:0"` | + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ratelimit.ratelimit.sourcecriterion.ipstrategy.ipv6Subnet=64" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ratelimit +spec: + ratelimit: + sourceCriterion: + ipStrategy: + ipv6Subnet: 64 +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ratelimit.ratelimit.sourcecriterion.ipstrategy.ipv6Subnet=64" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ratelimit: + ratelimit: + sourceCriterion: + ipStrategy: + ipv6Subnet: 64 +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ratelimit.ratelimit] + [http.middlewares.test-ratelimit.ratelimit.sourceCriterion.ipStrategy] + ipv6Subnet = 64 +``` + #### `sourceCriterion.requestHeaderName` Name of the header used to group incoming requests. diff --git a/docs/content/reference/dynamic-configuration/docker-labels.yml b/docs/content/reference/dynamic-configuration/docker-labels.yml index e9b9beabc..77fcdef87 100644 --- a/docs/content/reference/dynamic-configuration/docker-labels.yml +++ b/docs/content/reference/dynamic-configuration/docker-labels.yml @@ -85,15 +85,18 @@ - "traefik.http.middlewares.middleware13.ipallowlist.ipstrategy=true" - "traefik.http.middlewares.middleware13.ipallowlist.ipstrategy.depth=42" - "traefik.http.middlewares.middleware13.ipallowlist.ipstrategy.excludedips=foobar, foobar" +- "traefik.http.middlewares.middleware13.ipallowlist.ipstrategy.ipv6subnet=42" - "traefik.http.middlewares.middleware13.ipallowlist.rejectstatuscode=42" - "traefik.http.middlewares.middleware13.ipallowlist.sourcerange=foobar, foobar" - "traefik.http.middlewares.middleware14.ipwhitelist.ipstrategy=true" - "traefik.http.middlewares.middleware14.ipwhitelist.ipstrategy.depth=42" - "traefik.http.middlewares.middleware14.ipwhitelist.ipstrategy.excludedips=foobar, foobar" +- "traefik.http.middlewares.middleware14.ipwhitelist.ipstrategy.ipv6subnet=42" - "traefik.http.middlewares.middleware14.ipwhitelist.sourcerange=foobar, foobar" - "traefik.http.middlewares.middleware15.inflightreq.amount=42" - "traefik.http.middlewares.middleware15.inflightreq.sourcecriterion.ipstrategy.depth=42" - "traefik.http.middlewares.middleware15.inflightreq.sourcecriterion.ipstrategy.excludedips=foobar, foobar" +- "traefik.http.middlewares.middleware15.inflightreq.sourcecriterion.ipstrategy.ipv6subnet=42" - "traefik.http.middlewares.middleware15.inflightreq.sourcecriterion.requestheadername=foobar" - "traefik.http.middlewares.middleware15.inflightreq.sourcecriterion.requesthost=true" - "traefik.http.middlewares.middleware16.passtlsclientcert.info.issuer.commonname=true" @@ -125,6 +128,7 @@ - "traefik.http.middlewares.middleware18.ratelimit.period=42s" - "traefik.http.middlewares.middleware18.ratelimit.sourcecriterion.ipstrategy.depth=42" - "traefik.http.middlewares.middleware18.ratelimit.sourcecriterion.ipstrategy.excludedips=foobar, foobar" +- "traefik.http.middlewares.middleware18.ratelimit.sourcecriterion.ipstrategy.ipv6subnet=42" - "traefik.http.middlewares.middleware18.ratelimit.sourcecriterion.requestheadername=foobar" - "traefik.http.middlewares.middleware18.ratelimit.sourcecriterion.requesthost=true" - "traefik.http.middlewares.middleware19.redirectregex.permanent=true" diff --git a/docs/content/reference/dynamic-configuration/file.toml b/docs/content/reference/dynamic-configuration/file.toml index 269467380..18fc9bc84 100644 --- a/docs/content/reference/dynamic-configuration/file.toml +++ b/docs/content/reference/dynamic-configuration/file.toml @@ -227,12 +227,14 @@ [http.middlewares.Middleware13.ipAllowList.ipStrategy] depth = 42 excludedIPs = ["foobar", "foobar"] + ipv6Subnet = 42 [http.middlewares.Middleware14] [http.middlewares.Middleware14.ipWhiteList] sourceRange = ["foobar", "foobar"] [http.middlewares.Middleware14.ipWhiteList.ipStrategy] depth = 42 excludedIPs = ["foobar", "foobar"] + ipv6Subnet = 42 [http.middlewares.Middleware15] [http.middlewares.Middleware15.inFlightReq] amount = 42 @@ -242,6 +244,7 @@ [http.middlewares.Middleware15.inFlightReq.sourceCriterion.ipStrategy] depth = 42 excludedIPs = ["foobar", "foobar"] + ipv6Subnet = 42 [http.middlewares.Middleware16] [http.middlewares.Middleware16.passTLSClientCert] pem = true @@ -286,6 +289,7 @@ [http.middlewares.Middleware18.rateLimit.sourceCriterion.ipStrategy] depth = 42 excludedIPs = ["foobar", "foobar"] + ipv6Subnet = 42 [http.middlewares.Middleware19] [http.middlewares.Middleware19.redirectRegex] regex = "foobar" diff --git a/docs/content/reference/dynamic-configuration/file.yaml b/docs/content/reference/dynamic-configuration/file.yaml index 6f94af243..d0b0fc0fb 100644 --- a/docs/content/reference/dynamic-configuration/file.yaml +++ b/docs/content/reference/dynamic-configuration/file.yaml @@ -267,6 +267,7 @@ http: excludedIPs: - foobar - foobar + ipv6Subnet: 42 rejectStatusCode: 42 Middleware14: ipWhiteList: @@ -278,6 +279,7 @@ http: excludedIPs: - foobar - foobar + ipv6Subnet: 42 Middleware15: inFlightReq: amount: 42 @@ -287,6 +289,7 @@ http: excludedIPs: - foobar - foobar + ipv6Subnet: 42 requestHeaderName: foobar requestHost: true Middleware16: @@ -333,6 +336,7 @@ http: excludedIPs: - foobar - foobar + ipv6Subnet: 42 requestHeaderName: foobar requestHost: true Middleware19: diff --git a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml index 96816d8a4..226d4a2f2 100644 --- a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml +++ b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml @@ -1458,6 +1458,12 @@ spec: items: type: string type: array + ipv6Subnet: + description: IPv6Subnet configures Traefik to consider + all IPv6 addresses from the defined subnet as originating + from the same IP. Applies to RemoteAddrStrategy and + DepthStrategy. + type: integer type: object requestHeaderName: description: RequestHeaderName defines the name of the header @@ -1491,6 +1497,11 @@ spec: items: type: string type: array + ipv6Subnet: + description: IPv6Subnet configures Traefik to consider all + IPv6 addresses from the defined subnet as originating from + the same IP. Applies to RemoteAddrStrategy and DepthStrategy. + type: integer type: object rejectStatusCode: description: |- @@ -1523,6 +1534,11 @@ spec: items: type: string type: array + ipv6Subnet: + description: IPv6Subnet configures Traefik to consider all + IPv6 addresses from the defined subnet as originating from + the same IP. Applies to RemoteAddrStrategy and DepthStrategy. + type: integer type: object sourceRange: description: SourceRange defines the set of allowed IPs (or ranges @@ -1691,6 +1707,12 @@ spec: items: type: string type: array + ipv6Subnet: + description: IPv6Subnet configures Traefik to consider + all IPv6 addresses from the defined subnet as originating + from the same IP. Applies to RemoteAddrStrategy and + DepthStrategy. + type: integer type: object requestHeaderName: description: RequestHeaderName defines the name of the header diff --git a/docs/content/reference/dynamic-configuration/kv-ref.md b/docs/content/reference/dynamic-configuration/kv-ref.md index bd9b99a7a..01de7ee69 100644 --- a/docs/content/reference/dynamic-configuration/kv-ref.md +++ b/docs/content/reference/dynamic-configuration/kv-ref.md @@ -103,18 +103,21 @@ THIS FILE MUST NOT BE EDITED BY HAND | `traefik/http/middlewares/Middleware13/ipAllowList/ipStrategy/depth` | `42` | | `traefik/http/middlewares/Middleware13/ipAllowList/ipStrategy/excludedIPs/0` | `foobar` | | `traefik/http/middlewares/Middleware13/ipAllowList/ipStrategy/excludedIPs/1` | `foobar` | +| `traefik/http/middlewares/Middleware13/ipAllowList/ipStrategy/ipv6Subnet` | `42` | | `traefik/http/middlewares/Middleware13/ipAllowList/rejectStatusCode` | `42` | | `traefik/http/middlewares/Middleware13/ipAllowList/sourceRange/0` | `foobar` | | `traefik/http/middlewares/Middleware13/ipAllowList/sourceRange/1` | `foobar` | | `traefik/http/middlewares/Middleware14/ipWhiteList/ipStrategy/depth` | `42` | | `traefik/http/middlewares/Middleware14/ipWhiteList/ipStrategy/excludedIPs/0` | `foobar` | | `traefik/http/middlewares/Middleware14/ipWhiteList/ipStrategy/excludedIPs/1` | `foobar` | +| `traefik/http/middlewares/Middleware14/ipWhiteList/ipStrategy/ipv6Subnet` | `42` | | `traefik/http/middlewares/Middleware14/ipWhiteList/sourceRange/0` | `foobar` | | `traefik/http/middlewares/Middleware14/ipWhiteList/sourceRange/1` | `foobar` | | `traefik/http/middlewares/Middleware15/inFlightReq/amount` | `42` | | `traefik/http/middlewares/Middleware15/inFlightReq/sourceCriterion/ipStrategy/depth` | `42` | | `traefik/http/middlewares/Middleware15/inFlightReq/sourceCriterion/ipStrategy/excludedIPs/0` | `foobar` | | `traefik/http/middlewares/Middleware15/inFlightReq/sourceCriterion/ipStrategy/excludedIPs/1` | `foobar` | +| `traefik/http/middlewares/Middleware15/inFlightReq/sourceCriterion/ipStrategy/ipv6Subnet` | `42` | | `traefik/http/middlewares/Middleware15/inFlightReq/sourceCriterion/requestHeaderName` | `foobar` | | `traefik/http/middlewares/Middleware15/inFlightReq/sourceCriterion/requestHost` | `true` | | `traefik/http/middlewares/Middleware16/passTLSClientCert/info/issuer/commonName` | `true` | @@ -147,6 +150,7 @@ THIS FILE MUST NOT BE EDITED BY HAND | `traefik/http/middlewares/Middleware18/rateLimit/sourceCriterion/ipStrategy/depth` | `42` | | `traefik/http/middlewares/Middleware18/rateLimit/sourceCriterion/ipStrategy/excludedIPs/0` | `foobar` | | `traefik/http/middlewares/Middleware18/rateLimit/sourceCriterion/ipStrategy/excludedIPs/1` | `foobar` | +| `traefik/http/middlewares/Middleware18/rateLimit/sourceCriterion/ipStrategy/ipv6Subnet` | `42` | | `traefik/http/middlewares/Middleware18/rateLimit/sourceCriterion/requestHeaderName` | `foobar` | | `traefik/http/middlewares/Middleware18/rateLimit/sourceCriterion/requestHost` | `true` | | `traefik/http/middlewares/Middleware19/redirectRegex/permanent` | `true` | diff --git a/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml b/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml index 45b390972..baf3d5b1e 100644 --- a/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml +++ b/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml @@ -734,6 +734,12 @@ spec: items: type: string type: array + ipv6Subnet: + description: IPv6Subnet configures Traefik to consider + all IPv6 addresses from the defined subnet as originating + from the same IP. Applies to RemoteAddrStrategy and + DepthStrategy. + type: integer type: object requestHeaderName: description: RequestHeaderName defines the name of the header @@ -767,6 +773,11 @@ spec: items: type: string type: array + ipv6Subnet: + description: IPv6Subnet configures Traefik to consider all + IPv6 addresses from the defined subnet as originating from + the same IP. Applies to RemoteAddrStrategy and DepthStrategy. + type: integer type: object rejectStatusCode: description: |- @@ -799,6 +810,11 @@ spec: items: type: string type: array + ipv6Subnet: + description: IPv6Subnet configures Traefik to consider all + IPv6 addresses from the defined subnet as originating from + the same IP. Applies to RemoteAddrStrategy and DepthStrategy. + type: integer type: object sourceRange: description: SourceRange defines the set of allowed IPs (or ranges @@ -967,6 +983,12 @@ spec: items: type: string type: array + ipv6Subnet: + description: IPv6Subnet configures Traefik to consider + all IPv6 addresses from the defined subnet as originating + from the same IP. Applies to RemoteAddrStrategy and + DepthStrategy. + type: integer type: object requestHeaderName: description: RequestHeaderName defines the name of the header diff --git a/integration/fixtures/k8s/01-traefik-crd.yml b/integration/fixtures/k8s/01-traefik-crd.yml index 96816d8a4..226d4a2f2 100644 --- a/integration/fixtures/k8s/01-traefik-crd.yml +++ b/integration/fixtures/k8s/01-traefik-crd.yml @@ -1458,6 +1458,12 @@ spec: items: type: string type: array + ipv6Subnet: + description: IPv6Subnet configures Traefik to consider + all IPv6 addresses from the defined subnet as originating + from the same IP. Applies to RemoteAddrStrategy and + DepthStrategy. + type: integer type: object requestHeaderName: description: RequestHeaderName defines the name of the header @@ -1491,6 +1497,11 @@ spec: items: type: string type: array + ipv6Subnet: + description: IPv6Subnet configures Traefik to consider all + IPv6 addresses from the defined subnet as originating from + the same IP. Applies to RemoteAddrStrategy and DepthStrategy. + type: integer type: object rejectStatusCode: description: |- @@ -1523,6 +1534,11 @@ spec: items: type: string type: array + ipv6Subnet: + description: IPv6Subnet configures Traefik to consider all + IPv6 addresses from the defined subnet as originating from + the same IP. Applies to RemoteAddrStrategy and DepthStrategy. + type: integer type: object sourceRange: description: SourceRange defines the set of allowed IPs (or ranges @@ -1691,6 +1707,12 @@ spec: items: type: string type: array + ipv6Subnet: + description: IPv6Subnet configures Traefik to consider + all IPv6 addresses from the defined subnet as originating + from the same IP. Applies to RemoteAddrStrategy and + DepthStrategy. + type: integer type: object requestHeaderName: description: RequestHeaderName defines the name of the header diff --git a/pkg/config/dynamic/middleware_test.go b/pkg/config/dynamic/middleware_test.go new file mode 100644 index 000000000..87a0b34df --- /dev/null +++ b/pkg/config/dynamic/middleware_test.go @@ -0,0 +1,57 @@ +package dynamic + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_GetStrategy_ipv6Subnet(t *testing.T) { + testCases := []struct { + desc string + expectError bool + ipv6Subnet *int + }{ + { + desc: "Nil subnet", + }, + { + desc: "Zero subnet", + expectError: true, + ipv6Subnet: intPtr(0), + }, + { + desc: "Subnet greater that 128", + expectError: true, + ipv6Subnet: intPtr(129), + }, + { + desc: "Valid subnet", + ipv6Subnet: intPtr(128), + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + strategy := IPStrategy{ + IPv6Subnet: test.ipv6Subnet, + } + + get, err := strategy.Get() + if test.expectError { + require.Error(t, err) + assert.Nil(t, get) + } else { + require.NoError(t, err) + assert.NotNil(t, get) + } + }) + } +} + +func intPtr(value int) *int { + return &value +} diff --git a/pkg/config/dynamic/middlewares.go b/pkg/config/dynamic/middlewares.go index 9c65338a4..b2dcb084f 100644 --- a/pkg/config/dynamic/middlewares.go +++ b/pkg/config/dynamic/middlewares.go @@ -1,6 +1,7 @@ package dynamic import ( + "fmt" "net/http" "time" @@ -405,6 +406,8 @@ type IPStrategy struct { Depth int `json:"depth,omitempty" toml:"depth,omitempty" yaml:"depth,omitempty" export:"true"` // ExcludedIPs configures Traefik to scan the X-Forwarded-For header and select the first IP not in the list. ExcludedIPs []string `json:"excludedIPs,omitempty" toml:"excludedIPs,omitempty" yaml:"excludedIPs,omitempty"` + // IPv6Subnet configures Traefik to consider all IPv6 addresses from the defined subnet as originating from the same IP. Applies to RemoteAddrStrategy and DepthStrategy. + IPv6Subnet *int `json:"ipv6Subnet,omitempty" toml:"ipv6Subnet,omitempty" yaml:"ipv6Subnet,omitempty"` // TODO(mpl): I think we should make RemoteAddr an explicit field. For one thing, it would yield better documentation. } @@ -418,8 +421,13 @@ func (s *IPStrategy) Get() (ip.Strategy, error) { } if s.Depth > 0 { + if s.IPv6Subnet != nil && (*s.IPv6Subnet <= 0 || *s.IPv6Subnet > 128) { + return nil, fmt.Errorf("invalid IPv6 subnet %d value, should be greater to 0 and lower or equal to 128", *s.IPv6Subnet) + } + return &ip.DepthStrategy{ - Depth: s.Depth, + Depth: s.Depth, + IPv6Subnet: s.IPv6Subnet, }, nil } @@ -433,7 +441,13 @@ func (s *IPStrategy) Get() (ip.Strategy, error) { }, nil } - return &ip.RemoteAddrStrategy{}, nil + if s.IPv6Subnet != nil && (*s.IPv6Subnet <= 0 || *s.IPv6Subnet > 128) { + return nil, fmt.Errorf("invalid IPv6 subnet %d value, should be greater to 0 and lower or equal to 128", *s.IPv6Subnet) + } + + return &ip.RemoteAddrStrategy{ + IPv6Subnet: s.IPv6Subnet, + }, nil } // +k8s:deepcopy-gen=true diff --git a/pkg/config/dynamic/zz_generated.deepcopy.go b/pkg/config/dynamic/zz_generated.deepcopy.go index 890e89700..ba0e96606 100644 --- a/pkg/config/dynamic/zz_generated.deepcopy.go +++ b/pkg/config/dynamic/zz_generated.deepcopy.go @@ -704,6 +704,11 @@ func (in *IPStrategy) DeepCopyInto(out *IPStrategy) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.IPv6Subnet != nil { + in, out := &in.IPv6Subnet, &out.IPv6Subnet + *out = new(int) + **out = **in + } return } diff --git a/pkg/config/label/label_test.go b/pkg/config/label/label_test.go index ce020a438..be9c6f8cc 100644 --- a/pkg/config/label/label_test.go +++ b/pkg/config/label/label_test.go @@ -90,10 +90,12 @@ func TestDecodeConfiguration(t *testing.T) { "traefik.http.middlewares.Middleware8.headers.stsseconds": "42", "traefik.http.middlewares.Middleware9.ipallowlist.ipstrategy.depth": "42", "traefik.http.middlewares.Middleware9.ipallowlist.ipstrategy.excludedips": "foobar, fiibar", + "traefik.http.middlewares.Middleware9.ipallowlist.ipstrategy.ipv6subnet": "42", "traefik.http.middlewares.Middleware9.ipallowlist.sourcerange": "foobar, fiibar", "traefik.http.middlewares.Middleware10.inflightreq.amount": "42", "traefik.http.middlewares.Middleware10.inflightreq.sourcecriterion.ipstrategy.depth": "42", "traefik.http.middlewares.Middleware10.inflightreq.sourcecriterion.ipstrategy.excludedips": "foobar, fiibar", + "traefik.http.middlewares.Middleware10.inflightreq.sourcecriterion.ipstrategy.ipv6subnet": "42", "traefik.http.middlewares.Middleware10.inflightreq.sourcecriterion.requestheadername": "foobar", "traefik.http.middlewares.Middleware10.inflightreq.sourcecriterion.requesthost": "true", "traefik.http.middlewares.Middleware11.passtlsclientcert.info.notafter": "true", @@ -123,6 +125,7 @@ func TestDecodeConfiguration(t *testing.T) { "traefik.http.middlewares.Middleware12.ratelimit.sourcecriterion.requesthost": "true", "traefik.http.middlewares.Middleware12.ratelimit.sourcecriterion.ipstrategy.depth": "42", "traefik.http.middlewares.Middleware12.ratelimit.sourcecriterion.ipstrategy.excludedips": "foobar, foobar", + "traefik.http.middlewares.Middleware12.ratelimit.sourcecriterion.ipstrategy.ipv6subnet": "42", "traefik.http.middlewares.Middleware13.redirectregex.permanent": "true", "traefik.http.middlewares.Middleware13.redirectregex.regex": "foobar", "traefik.http.middlewares.Middleware13.redirectregex.replacement": "foobar", @@ -392,6 +395,7 @@ func TestDecodeConfiguration(t *testing.T) { IPStrategy: &dynamic.IPStrategy{ Depth: 42, ExcludedIPs: []string{"foobar", "fiibar"}, + IPv6Subnet: intPtr(42), }, RequestHeaderName: "foobar", RequestHost: true, @@ -437,6 +441,7 @@ func TestDecodeConfiguration(t *testing.T) { IPStrategy: &dynamic.IPStrategy{ Depth: 42, ExcludedIPs: []string{"foobar", "foobar"}, + IPv6Subnet: intPtr(42), }, RequestHeaderName: "foobar", RequestHost: true, @@ -648,6 +653,7 @@ func TestDecodeConfiguration(t *testing.T) { "foobar", "fiibar", }, + IPv6Subnet: intPtr(42), }, }, }, @@ -913,6 +919,7 @@ func TestEncodeConfiguration(t *testing.T) { IPStrategy: &dynamic.IPStrategy{ Depth: 42, ExcludedIPs: []string{"foobar", "fiibar"}, + IPv6Subnet: intPtr(42), }, RequestHeaderName: "foobar", RequestHost: true, @@ -957,6 +964,7 @@ func TestEncodeConfiguration(t *testing.T) { IPStrategy: &dynamic.IPStrategy{ Depth: 42, ExcludedIPs: []string{"foobar", "foobar"}, + IPv6Subnet: intPtr(42), }, RequestHeaderName: "foobar", RequestHost: true, @@ -1176,6 +1184,7 @@ func TestEncodeConfiguration(t *testing.T) { "foobar", "fiibar", }, + IPv6Subnet: intPtr(42), }, }, }, @@ -1338,11 +1347,13 @@ func TestEncodeConfiguration(t *testing.T) { "traefik.HTTP.Middlewares.Middleware8.Headers.STSSeconds": "42", "traefik.HTTP.Middlewares.Middleware9.IPAllowList.IPStrategy.Depth": "42", "traefik.HTTP.Middlewares.Middleware9.IPAllowList.IPStrategy.ExcludedIPs": "foobar, fiibar", + "traefik.HTTP.Middlewares.Middleware9.IPAllowList.IPStrategy.IPv6Subnet": "42", "traefik.HTTP.Middlewares.Middleware9.IPAllowList.RejectStatusCode": "0", "traefik.HTTP.Middlewares.Middleware9.IPAllowList.SourceRange": "foobar, fiibar", "traefik.HTTP.Middlewares.Middleware10.InFlightReq.Amount": "42", "traefik.HTTP.Middlewares.Middleware10.InFlightReq.SourceCriterion.IPStrategy.Depth": "42", "traefik.HTTP.Middlewares.Middleware10.InFlightReq.SourceCriterion.IPStrategy.ExcludedIPs": "foobar, fiibar", + "traefik.HTTP.Middlewares.Middleware10.InFlightReq.SourceCriterion.IPStrategy.IPv6Subnet": "42", "traefik.HTTP.Middlewares.Middleware10.InFlightReq.SourceCriterion.RequestHeaderName": "foobar", "traefik.HTTP.Middlewares.Middleware10.InFlightReq.SourceCriterion.RequestHost": "true", "traefik.HTTP.Middlewares.Middleware11.PassTLSClientCert.Info.NotAfter": "true", @@ -1372,6 +1383,7 @@ func TestEncodeConfiguration(t *testing.T) { "traefik.HTTP.Middlewares.Middleware12.RateLimit.SourceCriterion.RequestHost": "true", "traefik.HTTP.Middlewares.Middleware12.RateLimit.SourceCriterion.IPStrategy.Depth": "42", "traefik.HTTP.Middlewares.Middleware12.RateLimit.SourceCriterion.IPStrategy.ExcludedIPs": "foobar, foobar", + "traefik.HTTP.Middlewares.Middleware12.RateLimit.SourceCriterion.IPStrategy.IPv6Subnet": "42", "traefik.HTTP.Middlewares.Middleware13.RedirectRegex.Regex": "foobar", "traefik.HTTP.Middlewares.Middleware13.RedirectRegex.Replacement": "foobar", "traefik.HTTP.Middlewares.Middleware13.RedirectRegex.Permanent": "true", @@ -1486,3 +1498,7 @@ func TestEncodeConfiguration(t *testing.T) { } assert.Equal(t, expected, labels) } + +func intPtr(value int) *int { + return &value +} diff --git a/pkg/ip/strategy.go b/pkg/ip/strategy.go index 16e150e95..ed5e570a4 100644 --- a/pkg/ip/strategy.go +++ b/pkg/ip/strategy.go @@ -3,6 +3,7 @@ package ip import ( "net" "net/http" + "net/netip" "strings" ) @@ -16,7 +17,10 @@ type Strategy interface { } // RemoteAddrStrategy a strategy that always return the remote address. -type RemoteAddrStrategy struct{} +type RemoteAddrStrategy struct { + // IPv6Subnet instructs the strategy to return the first IP of the subnet where IP belongs. + IPv6Subnet *int +} // GetIP returns the selected IP. func (s *RemoteAddrStrategy) GetIP(req *http.Request) string { @@ -24,15 +28,22 @@ func (s *RemoteAddrStrategy) GetIP(req *http.Request) string { if err != nil { return req.RemoteAddr } + + if s.IPv6Subnet != nil { + return getIPv6SubnetIP(ip, *s.IPv6Subnet) + } + return ip } // DepthStrategy a strategy based on the depth inside the X-Forwarded-For from right to left. type DepthStrategy struct { Depth int + // IPv6Subnet instructs the strategy to return the first IP of the subnet where IP belongs. + IPv6Subnet *int } -// GetIP return the selected IP. +// GetIP returns the selected IP. func (s *DepthStrategy) GetIP(req *http.Request) string { xff := req.Header.Get(xForwardedFor) xffs := strings.Split(xff, ",") @@ -40,7 +51,14 @@ func (s *DepthStrategy) GetIP(req *http.Request) string { if len(xffs) < s.Depth { return "" } - return strings.TrimSpace(xffs[len(xffs)-s.Depth]) + + ip := strings.TrimSpace(xffs[len(xffs)-s.Depth]) + + if s.IPv6Subnet != nil { + return getIPv6SubnetIP(ip, *s.IPv6Subnet) + } + + return ip } // PoolStrategy is a strategy based on an IP Checker. @@ -72,3 +90,23 @@ func (s *PoolStrategy) GetIP(req *http.Request) string { return "" } + +// getIPv6SubnetIP returns the IPv6 subnet IP. +// It returns the original IP when it is not an IPv6, or if parsing the IP has failed with an error. +func getIPv6SubnetIP(ip string, ipv6Subnet int) string { + addr, err := netip.ParseAddr(ip) + if err != nil { + return ip + } + + if !addr.Is6() { + return ip + } + + prefix, err := addr.Prefix(ipv6Subnet) + if err != nil { + return ip + } + + return prefix.Addr().String() +} diff --git a/pkg/ip/strategy_test.go b/pkg/ip/strategy_test.go index b836223eb..08178525a 100644 --- a/pkg/ip/strategy_test.go +++ b/pkg/ip/strategy_test.go @@ -9,23 +9,81 @@ import ( "github.com/stretchr/testify/require" ) +const ( + ipv6Basic = "::abcd:ffff:c0a8:1" + ipv6BracketsPort = "[::abcd:ffff:c0a8:1]:80" + ipv6BracketsZonePort = "[::abcd:ffff:c0a8:1%1]:80" +) + func TestRemoteAddrStrategy_GetIP(t *testing.T) { testCases := []struct { - desc string - expected string + desc string + expected string + remoteAddr string + ipv6Subnet *int }{ + // Valid IP format { - desc: "Use RemoteAddr", + desc: "Use RemoteAddr, ipv4", expected: "192.0.2.1", }, + { + desc: "Use RemoteAddr, ipv6 brackets with port, no IPv6 subnet", + remoteAddr: ipv6BracketsPort, + expected: "::abcd:ffff:c0a8:1", + }, + { + desc: "Use RemoteAddr, ipv6 brackets with zone and port, no IPv6 subnet", + remoteAddr: ipv6BracketsZonePort, + expected: "::abcd:ffff:c0a8:1%1", + }, + + // Invalid IPv6 format + { + desc: "Use RemoteAddr, ipv6 basic, missing brackets, no IPv6 subnet", + remoteAddr: ipv6Basic, + expected: ipv6Basic, + }, + + // Valid IP format with subnet + { + desc: "Use RemoteAddr, ipv4, ignore subnet", + expected: "192.0.2.1", + ipv6Subnet: intPtr(24), + }, + { + desc: "Use RemoteAddr, ipv6 brackets with port, subnet", + remoteAddr: ipv6BracketsPort, + expected: "::abcd:0:0:0", + ipv6Subnet: intPtr(80), + }, + { + desc: "Use RemoteAddr, ipv6 brackets with zone and port, subnet", + remoteAddr: ipv6BracketsZonePort, + expected: "::abcd:0:0:0", + ipv6Subnet: intPtr(80), + }, + + // Valid IP, invalid subnet + { + desc: "Use RemoteAddr, ipv6 brackets with port, invalid subnet", + remoteAddr: ipv6BracketsPort, + expected: "::abcd:ffff:c0a8:1", + ipv6Subnet: intPtr(500), + }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() - strategy := RemoteAddrStrategy{} + strategy := RemoteAddrStrategy{ + IPv6Subnet: test.ipv6Subnet, + } req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1", nil) + if test.remoteAddr != "" { + req.RemoteAddr = test.remoteAddr + } actual := strategy.GetIP(req) assert.Equal(t, test.expected, actual) }) @@ -38,6 +96,7 @@ func TestDepthStrategy_GetIP(t *testing.T) { depth int xForwardedFor string expected string + ipv6Subnet *int }{ { desc: "Use depth", @@ -57,13 +116,30 @@ func TestDepthStrategy_GetIP(t *testing.T) { xForwardedFor: "10.0.0.2,10.0.0.1", expected: "10.0.0.2", }, + { + desc: "Use depth with IPv4 subnet", + depth: 2, + xForwardedFor: "10.0.0.3,10.0.0.2,10.0.0.1", + expected: "10.0.0.2", + ipv6Subnet: intPtr(80), + }, + { + desc: "Use depth with IPv6 subnet", + depth: 2, + xForwardedFor: "10.0.0.3," + ipv6Basic + ",10.0.0.1", + expected: "::abcd:0:0:0", + ipv6Subnet: intPtr(80), + }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() - strategy := DepthStrategy{Depth: test.depth} + strategy := DepthStrategy{ + Depth: test.depth, + IPv6Subnet: test.ipv6Subnet, + } req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1", nil) req.Header.Set(xForwardedFor, test.xForwardedFor) actual := strategy.GetIP(req) @@ -121,3 +197,7 @@ func TestTrustedIPsStrategy_GetIP(t *testing.T) { }) } } + +func intPtr(value int) *int { + return &value +}