Skip to content

Commit bc5657f

Browse files
Copilotnitrocode
andcommitted
feat: clamp glob cache minimums (TTL≥1s, entries≥16) and strip default ports in normalizeHost
Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com> Agent-Logs-Url: https://github.com/cloudposse/atmos/sessions/a2999beb-54fd-42e7-b1f4-c50844590385
1 parent 69b1d82 commit bc5657f

File tree

6 files changed

+101
-11
lines changed

6 files changed

+101
-11
lines changed

CHANGELOG.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@ See [Conventional Commits](https://www.conventionalcommits.org/) for commit mess
1212
- **`pkg/filesystem.GetGlobMatches`**: always returns a non-nil `[]string{}` (never `nil`).
1313
Callers must use `len(result) == 0` to detect no matches instead of `result == nil`.
1414
The cache is now bounded and configurable via three environment variables:
15-
- `ATMOS_FS_GLOB_CACHE_MAX_ENTRIES` (default `1024`) — maximum number of cached glob patterns.
16-
- `ATMOS_FS_GLOB_CACHE_TTL` (default `5m`) — time-to-live for each cache entry.
15+
- `ATMOS_FS_GLOB_CACHE_MAX_ENTRIES` (default `1024`, minimum `16`) — maximum number of cached glob patterns.
16+
- `ATMOS_FS_GLOB_CACHE_TTL` (default `5m`, minimum `1s`) — time-to-live for each cache entry.
17+
Values below the respective minimums are clamped up rather than rejected.
1718
- `ATMOS_FS_GLOB_CACHE_EMPTY` (default `1`) — set to `0` to skip caching patterns that match no files.
19+
- **`pkg/http.normalizeHost`**: now strips default ports (`:443`, `:80`) in addition to
20+
lower-casing and removing trailing dots, so that `api.github.com:443` is treated
21+
identically to `api.github.com` for allowlist matching.
1822

1923
### Added
2024

pkg/filesystem/doc.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
// The glob LRU cache is configurable at startup via environment variables:
1414
//
1515
// - ATMOS_FS_GLOB_CACHE_MAX_ENTRIES – maximum number of cached patterns
16-
// (default: 1024).
16+
// (default: 1024, minimum: 16; values below 16 are clamped up).
1717
// - ATMOS_FS_GLOB_CACHE_TTL – TTL per entry as a Go duration string, e.g.
18-
// "10m" (default: 5m).
18+
// "10m" (default: 5m, minimum: 1s; values below 1s are clamped up).
1919
// - ATMOS_FS_GLOB_CACHE_EMPTY – set to "0" or "false" to disable caching
2020
// of empty (no-match) results (default: "1" = enabled).
2121
//

pkg/filesystem/glob.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ const (
2424
// defaultGlobCacheTTL is the default time-to-live for each cache entry.
2525
// Override at startup with ATMOS_FS_GLOB_CACHE_TTL (e.g. "10m", "30s").
2626
defaultGlobCacheTTL = 5 * time.Minute
27+
28+
// minGlobCacheTTL is the minimum accepted TTL value. Values parsed from
29+
// ATMOS_FS_GLOB_CACHE_TTL that are positive but below this floor are clamped up.
30+
// A sub-second TTL would make the cache nearly useless and cause excessive I/O.
31+
minGlobCacheTTL = time.Second
32+
33+
// minGlobCacheMaxEntries is the minimum accepted LRU capacity. Values parsed
34+
// from ATMOS_FS_GLOB_CACHE_MAX_ENTRIES that are positive but below this floor
35+
// are clamped up to prevent near-empty caches that evict on nearly every call.
36+
minGlobCacheMaxEntries = 16
2737
)
2838

2939
// globCacheEntry holds a cached glob result together with its expiry timestamp.
@@ -78,6 +88,9 @@ func applyGlobCacheConfig() {
7888
//nolint:forbidigo // Direct env lookup required for cache configuration.
7989
if v := os.Getenv("ATMOS_FS_GLOB_CACHE_MAX_ENTRIES"); v != "" {
8090
if n, err := strconv.Atoi(v); err == nil && n > 0 {
91+
if n < minGlobCacheMaxEntries {
92+
n = minGlobCacheMaxEntries
93+
}
8194
maxEntries = n
8295
}
8396
}
@@ -86,6 +99,9 @@ func applyGlobCacheConfig() {
8699
//nolint:forbidigo // Direct env lookup required for cache configuration.
87100
if v := os.Getenv("ATMOS_FS_GLOB_CACHE_TTL"); v != "" {
88101
if d, err := time.ParseDuration(v); err == nil && d > 0 {
102+
if d < minGlobCacheTTL {
103+
d = minGlobCacheTTL
104+
}
89105
ttl = d
90106
}
91107
}
@@ -133,8 +149,8 @@ func init() {
133149
// Callers should always use len(result) == 0 to detect no matches, not a nil comparison.
134150
//
135151
// Caching policy:
136-
// - Results are bounded to a configurable LRU (default 1024 entries, ATMOS_FS_GLOB_CACHE_MAX_ENTRIES).
137-
// - Each entry expires after a configurable TTL (default 5 minutes, ATMOS_FS_GLOB_CACHE_TTL).
152+
// - Results are bounded to a configurable LRU (default 1024 entries, minimum 16, ATMOS_FS_GLOB_CACHE_MAX_ENTRIES).
153+
// - Each entry expires after a configurable TTL (default 5 minutes, minimum 1s, ATMOS_FS_GLOB_CACHE_TTL).
138154
// - Empty results are cached by default; set ATMOS_FS_GLOB_CACHE_EMPTY=0 to disable.
139155
// - Cached slices are cloned on read, so callers may safely mutate the returned slice.
140156
func GetGlobMatches(pattern string) ([]string, error) {

pkg/filesystem/glob_atomic_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,43 @@ func TestApplyGlobCacheConfig_InvalidInputsClamped(t *testing.T) {
614614
wantTTL: defaultTTL,
615615
wantMaxEntries: 256,
616616
},
617+
// Values below the minimum should be clamped up, not rejected.
618+
{
619+
name: "TTL_below_minimum_clamped_to_1s",
620+
ttlEnv: "100ms",
621+
wantTTL: time.Second,
622+
wantMaxEntries: defaultMaxEntries,
623+
},
624+
{
625+
name: "TTL_500ms_clamped_to_1s",
626+
ttlEnv: "500ms",
627+
wantTTL: time.Second,
628+
wantMaxEntries: defaultMaxEntries,
629+
},
630+
{
631+
name: "maxEntries_below_minimum_clamped_to_16",
632+
maxEntriesEnv: "5",
633+
wantTTL: defaultTTL,
634+
wantMaxEntries: 16,
635+
},
636+
{
637+
name: "maxEntries_15_clamped_to_16",
638+
maxEntriesEnv: "15",
639+
wantTTL: defaultTTL,
640+
wantMaxEntries: 16,
641+
},
642+
{
643+
name: "maxEntries_exactly_16_accepted",
644+
maxEntriesEnv: "16",
645+
wantTTL: defaultTTL,
646+
wantMaxEntries: 16,
647+
},
648+
{
649+
name: "TTL_exactly_1s_accepted",
650+
ttlEnv: "1s",
651+
wantTTL: time.Second,
652+
wantMaxEntries: defaultMaxEntries,
653+
},
617654
}
618655

619656
for _, tc := range cases {

pkg/http/client.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"errors"
88
"fmt"
99
"io"
10+
"net"
1011
"net/http"
1112
"net/url"
1213
"os"
@@ -186,14 +187,24 @@ type GitHubAuthenticatedTransport struct {
186187
}
187188

188189
// normalizeHost canonicalizes a hostname for allowlist comparison:
189-
// it lower-cases the string and strips a trailing dot (FQDN form).
190-
// Port stripping is explicitly NOT performed here; callers should pass
191-
// the result of url.URL.Hostname() (which already has the port removed)
192-
// rather than the raw url.URL.Host field. This avoids incorrectly
193-
// truncating IPv6 literals (e.g., [::1]) which contain colons.
190+
// it lower-cases the string, strips a trailing dot (FQDN form), and removes
191+
// default HTTP/HTTPS ports (:80 and :443) so that "api.github.com:443" is
192+
// treated identically to "api.github.com".
193+
//
194+
// net.SplitHostPort is used to handle IPv6 literals safely (e.g., "[::1]:443"
195+
// is split to "::1" and "443", and the brackets are dropped for comparison).
196+
// Non-default ports (e.g., :8443) are preserved unchanged.
197+
//
198+
// Note: in the hot path, callers pass url.URL.Hostname() which already strips
199+
// the port, making the port-stripping here a defence-in-depth measure.
194200
func normalizeHost(host string) string {
195201
host = strings.ToLower(host)
196202
host = strings.TrimSuffix(host, ".")
203+
// Strip default ports so that "api.github.com:443" matches "api.github.com".
204+
// Also strip any trailing dot from the host part (handles "api.github.com.:443").
205+
if h, port, err := net.SplitHostPort(host); err == nil && (port == "443" || port == "80") {
206+
host = strings.TrimSuffix(h, ".")
207+
}
197208
return host
198209
}
199210

pkg/http/client_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,6 +1147,20 @@ func TestNormalizeHost(t *testing.T) {
11471147
// Upper-case + trailing dot.
11481148
{"API.GITHUB.COM.", "api.github.com"},
11491149
{"", ""},
1150+
// Default port 443 should be stripped.
1151+
{"api.github.com:443", "api.github.com"},
1152+
// Default port 80 should be stripped.
1153+
{"api.github.com:80", "api.github.com"},
1154+
// Non-default port should be preserved.
1155+
{"api.github.com:8443", "api.github.com:8443"},
1156+
// Port 443 + upper-case: both normalised.
1157+
{"API.GITHUB.COM:443", "api.github.com"},
1158+
// Port 443 + trailing dot: trailing dot stripped then port stripped.
1159+
{"api.github.com.:443", "api.github.com"},
1160+
// IPv6 with default port: brackets are stripped by net.SplitHostPort.
1161+
{"[::1]:443", "::1"},
1162+
// IPv6 with non-default port: preserved (with brackets stripped by SplitHostPort).
1163+
{"[::1]:8080", "[::1]:8080"},
11501164
}
11511165

11521166
for _, tt := range tests {
@@ -1167,6 +1181,11 @@ func TestIsGitHubHost_CaseAndTrailingDot(t *testing.T) {
11671181
"API.GITHUB.COM.",
11681182
"Raw.GitHubUserContent.com",
11691183
"UPLOADS.GITHUB.COM",
1184+
// Port variants: default port should be stripped before matching.
1185+
"api.github.com:443",
1186+
"API.GITHUB.COM:443",
1187+
"uploads.github.com:443",
1188+
"raw.githubusercontent.com:80",
11701189
}
11711190
for _, h := range positives {
11721191
assert.True(t, isGitHubHost(h), "expected %q to be allowed", h)
@@ -1176,6 +1195,9 @@ func TestIsGitHubHost_CaseAndTrailingDot(t *testing.T) {
11761195
"GITHUB.EXAMPLE.COM",
11771196
"EXAMPLE.GITHUB.COM",
11781197
"github.com",
1198+
// Port variants on disallowed hosts should still be denied.
1199+
"github.example.com:443",
1200+
"example.github.com:443",
11791201
}
11801202
for _, h := range negatives {
11811203
assert.False(t, isGitHubHost(h), "expected %q to be denied", h)

0 commit comments

Comments
 (0)