Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions documentation/annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ more info about custom annotations can be found in [annotations-custom.md](annot
| [rate-limit-requests](#rate-limit) | number | | |:large_blue_circle:|:large_blue_circle:|:white_circle:|
| [rate-limit-size](#rate-limit) | string | "100k" | rate-limit |:large_blue_circle:|:large_blue_circle:|:white_circle:|
| [rate-limit-whitelist](#rate-limit) | IPs/CIDRs or pattern file | | |:large_blue_circle:|:large_blue_circle:|:white_circle:|
| [rate-limit-exclude-path-end](#rate-limit) | path suffixes | | rate-limit-requests |:large_blue_circle:|:large_blue_circle:|:white_circle:|
| [request-capture](#request-capture) | [sample expression](#sample-expression) | | |:large_blue_circle:|:large_blue_circle:|:white_circle:|
| [request-capture-len](#request-capture) | number | 128 | |:large_blue_circle:|:large_blue_circle:|:white_circle:|
| [request-set-header](#request-set-header) | string | | |:large_blue_circle:|:large_blue_circle:|:white_circle:|
Expand Down Expand Up @@ -1520,6 +1521,27 @@ rate-limit-whitelist: "10.0.0.0/8, 192.168.1.100"

```

##### `rate-limit-exclude-path-end`

Defines a list of path suffixes (e.g. static asset extensions) that are excluded from rate limiting. Matching requests are neither counted against the rate nor denied, so an asset-heavy page load does not consume the limit and can never be partially blocked.

Available on: `configmap` `ingress`

:information_source: Matching uses HAProxy `path_end` and is case-sensitive.

Possible values:

- Comma- and/or space-separated list of path suffixes (e.g., `.css, .js, robots.txt`). Allowed characters are `A-Z a-z 0-9 . _ / -`
- Prefix the value with `+` to extend the ConfigMap default instead of replacing it (e.g., `+.pdf .zip` on an Ingress adds two suffixes to the ConfigMap list)

Example:

```yaml
rate-limit-requests: 20
rate-limit-exclude-path-end: ".css .js .png .jpg .svg .woff2 robots.txt"

```

<p align='right'><a href='#available-annotations'>:arrow_up_small: back to top</a></p>

***
Expand Down
30 changes: 30 additions & 0 deletions documentation/doc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1560,6 +1560,36 @@ annotations:
- In this example, most clients can make up to 1200 requests per 10 seconds.
Clients from `10.0.0.0/8` or IP `192.168.1.100` are never rate limited. When
the limit is exceeded for non-whitelisted IPs, a 429 status code is returned.
- title: rate-limit-exclude-path-end
type: path suffixes
group: rate-limit
dependencies: rate-limit-requests
default: ""
description:
- Defines a list of path suffixes (e.g. static asset extensions) that are
excluded from rate limiting. Matching requests are neither counted
against the rate nor denied, so an asset-heavy page load does not
consume the limit and can never be partially blocked.
tip:
- Matching uses HAProxy `path_end` and is case-sensitive.
values:
- Comma- and/or space-separated list of path suffixes (e.g., `.css, .js,
robots.txt`). Allowed characters are `A-Z a-z 0-9 . _ / -`
- Prefix the value with `+` to extend the ConfigMap default instead of
replacing it (e.g., `+.pdf .zip` on an Ingress adds two suffixes to the
ConfigMap list)
applies_to:
- configmap
- ingress
version_min: "3.2"
example:
- |
rate-limit-requests: 20
rate-limit-exclude-path-end: ".css .js .png .jpg .svg .woff2 robots.txt"
example_notes:
- In this example, clients can make up to 20 non-asset requests per second.
Requests for the listed static suffixes are never counted or denied, so
page loads with many assets are not affected by the limit.
- title: request-capture
type: "[sample expression](#sample-expression)"
group: request-capture
Expand Down
66 changes: 34 additions & 32 deletions pkg/annotations/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ func (a annImpl) Frontend(i *store.Ingress, r *rules.List, m maps.Maps) []Annota
reqRateLimit.NewAnnotation("rate-limit-size"),
reqRateLimit.NewAnnotation("rate-limit-status-code"),
reqRateLimit.NewAnnotation("rate-limit-whitelist"),
reqRateLimit.NewAnnotation("rate-limit-exclude-path-end"),
reqAuth.NewAnnotation("auth-type"),
reqAuth.NewAnnotation("auth-realm"),
reqAuth.NewAnnotation("auth-secret"),
Expand Down Expand Up @@ -280,36 +281,37 @@ func BackendNames() []string {
// SpecificAnnotations is a set of annotations that uses rules to produce specific configuration with rule ID in configuration file.
// These annotations in an ingress can't be merged with other ingresses annotations when these ingresses point to the same service because specific paths must be treated specifically.
var SpecificAnnotations = map[string]struct{}{
"backend-config-snippet": {},
"deny-list": {},
"blacklist": {},
"allow-list": {},
"whitelist": {},
"src-ip-header": {},
"auth-type": {},
"auth-realm": {},
"auth-secret": {},
"ssl-redirect": {},
"ssl-redirect-port": {},
"ssl-redirect-code": {},
"request-redirect": {},
"request-redirect-code": {},
"request-capture": {},
"request-capture-len": {},
"path-rewrite": {},
"rate-limit-requests": {},
"rate-limit-period": {},
"rate-limit-size": {},
"rate-limit-status-code": {},
"rate-limit-whitelist": {},
"request-set-header": {},
"response-set-header": {},
"set-host": {},
"cors-enable": {},
"cors-allow-origin": {},
"cors-allow-methods": {},
"cors-allow-headers": {},
"cors-max-age": {},
"cors-allow-credentials": {},
"cors-respond-to-options": {},
"backend-config-snippet": {},
"deny-list": {},
"blacklist": {},
"allow-list": {},
"whitelist": {},
"src-ip-header": {},
"auth-type": {},
"auth-realm": {},
"auth-secret": {},
"ssl-redirect": {},
"ssl-redirect-port": {},
"ssl-redirect-code": {},
"request-redirect": {},
"request-redirect-code": {},
"request-capture": {},
"request-capture-len": {},
"path-rewrite": {},
"rate-limit-requests": {},
"rate-limit-period": {},
"rate-limit-size": {},
"rate-limit-status-code": {},
"rate-limit-whitelist": {},
"rate-limit-exclude-path-end": {},
"request-set-header": {},
"response-set-header": {},
"set-host": {},
"cors-enable": {},
"cors-allow-origin": {},
"cors-allow-methods": {},
"cors-allow-headers": {},
"cors-max-age": {},
"cors-allow-credentials": {},
"cors-respond-to-options": {},
}
41 changes: 41 additions & 0 deletions pkg/annotations/ingress/reqRateLimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net"
"regexp"
"strconv"
"strings"

Expand All @@ -14,6 +15,11 @@ import (
"github.com/haproxytech/kubernetes-ingress/pkg/utils"
)

// excludePathEndEntry validates path suffixes passed to
// rate-limit-exclude-path-end so annotation values cannot inject arbitrary
// HAProxy configuration into the generated ACL conditions.
var excludePathEndEntry = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`)

type ReqRateLimit struct {
limit *rules.ReqRateLimit
track *rules.ReqTrack
Expand Down Expand Up @@ -118,6 +124,41 @@ func (a ReqRateLimitAnn) Process(k store.K8s, annotations ...map[string]string)

// Store pattern file references
a.parent.limit.WhitelistMaps = patterns
case "rate-limit-exclude-path-end":
if a.parent.limit == nil || a.parent.track == nil {
return errors.New("rate-limit-exclude-path-end requires rate-limit-requests to be set")
}

var values []string
for _, source := range annotations {
value, found := source[a.name]
if !found || value == "" {
continue
}
if extended := strings.TrimPrefix(value, "+"); extended != value {
values = append(values, extended)
continue
}
values = append(values, value)
break
}
input = strings.Join(values, " ")

var suffixes []string
for _, entry := range strings.FieldsFunc(input, func(r rune) bool { return r == ',' || r == ' ' }) {
if !excludePathEndEntry.MatchString(entry) {
return fmt.Errorf("incorrect path suffix '%s' in %s annotation", entry, a.name)
}
suffixes = append(suffixes, entry)
}
if len(suffixes) == 0 {
return fmt.Errorf("no path suffixes in %s annotation", a.name)
}

// Excluded requests are neither tracked nor denied: they don't
// increment the rate counter and are never rate limited.
a.parent.track.ExcludePathEnd = suffixes
a.parent.limit.ExcludePathEnd = suffixes
default:
err = fmt.Errorf("unknown rate-limit annotation '%s'", a.name)
}
Expand Down
150 changes: 150 additions & 0 deletions pkg/annotations/ingress/reqRateLimit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,3 +289,153 @@ func TestReqRateLimit_AllAnnotations(t *testing.T) {
assert.Contains(t, reqRateLimit.limit.WhitelistIPs, "192.168.1.100")
assert.NotNil(t, reqRateLimit.track.TableSize)
}

// TestReqRateLimit_ExcludePathEnd tests the rate-limit-exclude-path-end annotation processing.
// It validates that:
// - Comma-separated and space-separated suffix lists are parsed correctly
// - Suffixes are stored on BOTH the track rule (so excluded requests are not
// counted) and the limit rule (so they are not denied)
// - The annotation fails when rate-limit-requests is not configured first
// - Entries with characters outside [A-Za-z0-9._/-] are rejected (config injection guard)
// - An input with no usable entries is rejected
func TestReqRateLimit_ExcludePathEnd(t *testing.T) {
tests := []struct {
name string
annotations map[string]string
wantErr bool
expectedSuffixes []string
}{
{
name: "comma separated suffixes",
annotations: map[string]string{
"rate-limit-requests": "100",
"rate-limit-exclude-path-end": ".css, .js, robots.txt",
},
expectedSuffixes: []string{".css", ".js", "robots.txt"},
},
{
name: "space separated suffixes",
annotations: map[string]string{
"rate-limit-requests": "100",
"rate-limit-exclude-path-end": ".css .js .woff2",
},
expectedSuffixes: []string{".css", ".js", ".woff2"},
},
{
name: "missing rate-limit-requests",
annotations: map[string]string{
"rate-limit-exclude-path-end": ".css",
},
wantErr: true,
},
{
name: "invalid characters rejected",
annotations: map[string]string{
"rate-limit-requests": "100",
"rate-limit-exclude-path-end": ".css }{ always_true",
},
wantErr: true,
},
{
name: "empty list rejected",
annotations: map[string]string{
"rate-limit-requests": "100",
"rate-limit-exclude-path-end": ", ,",
},
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockMaps, err := maps.New("/tmp/maps", nil)
require.NoError(t, err)
rulesList := &rules.List{}
reqRateLimit := NewReqRateLimit(rulesList, mockMaps)

if _, ok := tt.annotations["rate-limit-requests"]; ok {
ann := reqRateLimit.NewAnnotation("rate-limit-requests")
require.NoError(t, ann.Process(store.K8s{}, tt.annotations))
}

ann := reqRateLimit.NewAnnotation("rate-limit-exclude-path-end")
err = ann.Process(store.K8s{}, tt.annotations)

if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expectedSuffixes, reqRateLimit.limit.ExcludePathEnd)
assert.Equal(t, tt.expectedSuffixes, reqRateLimit.track.ExcludePathEnd)
})
}
}

// TestReqRateLimit_ExcludePathEndCascade tests the multi-source semantics of
// rate-limit-exclude-path-end (Ingress annotations first, ConfigMap second).
// It validates that:
// - A ConfigMap-only value acts as the default for every ingress
// - A plain Ingress value replaces the ConfigMap default entirely
// - An Ingress value starting with '+' extends the ConfigMap default
// - A '+' value with nothing below it still works on its own
func TestReqRateLimit_ExcludePathEndCascade(t *testing.T) {
tests := []struct {
name string
ingressAnns map[string]string
configMapAnns map[string]string
expectedSuffixes []string
}{
{
name: "configmap default only",
ingressAnns: map[string]string{"rate-limit-requests": "100"},
configMapAnns: map[string]string{"rate-limit-exclude-path-end": ".css .js"},
expectedSuffixes: []string{".css", ".js"},
},
{
name: "ingress replaces configmap default",
ingressAnns: map[string]string{
"rate-limit-requests": "100",
"rate-limit-exclude-path-end": ".pdf",
},
configMapAnns: map[string]string{"rate-limit-exclude-path-end": ".css .js"},
expectedSuffixes: []string{".pdf"},
},
{
name: "ingress extends configmap default with + prefix",
ingressAnns: map[string]string{
"rate-limit-requests": "100",
"rate-limit-exclude-path-end": "+.pdf .zip",
},
configMapAnns: map[string]string{"rate-limit-exclude-path-end": ".css .js"},
expectedSuffixes: []string{".pdf", ".zip", ".css", ".js"},
},
{
name: "+ prefix with no configmap default",
ingressAnns: map[string]string{
"rate-limit-requests": "100",
"rate-limit-exclude-path-end": "+.pdf",
},
configMapAnns: map[string]string{},
expectedSuffixes: []string{".pdf"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockMaps, err := maps.New("/tmp/maps", nil)
require.NoError(t, err)
rulesList := &rules.List{}
reqRateLimit := NewReqRateLimit(rulesList, mockMaps)

ann := reqRateLimit.NewAnnotation("rate-limit-requests")
require.NoError(t, ann.Process(store.K8s{}, tt.ingressAnns, tt.configMapAnns))

ann = reqRateLimit.NewAnnotation("rate-limit-exclude-path-end")
require.NoError(t, ann.Process(store.K8s{}, tt.ingressAnns, tt.configMapAnns))

assert.Equal(t, tt.expectedSuffixes, reqRateLimit.limit.ExcludePathEnd)
assert.Equal(t, tt.expectedSuffixes, reqRateLimit.track.ExcludePathEnd)
})
}
}
Loading