Skip to content

Commit d45a34b

Browse files
authored
feat(core): add additive CORS configuration fields (#2941)
### Proposed Changes Add `additionalheaders`, `additionalmethods`, and `additionalexposedheaders` fields to CORS config, allowing operators to append custom values without replacing the entire default list. - Add mergeStringSlices() for case-sensitive method deduplication - Add mergeHeaderSlices() for case-insensitive header deduplication (RFC 7230) - Add Effective*() methods to compute merged values at runtime - Log effective CORS values at startup for operator visibility - Add unit tests for merge functions and Effective* methods - Update docs/Configuring.md with CORS configuration section Operators can now add custom headers without copying all defaults: ```yaml server: cors: additionalheaders: - X-Custom-Header ``` Backwards compatible: existing configs work unchanged. ### Checklist - [ ] I have added or updated unit tests - [ ] I have added or updated integration tests (if appropriate) - [ ] I have added or updated documentation ### Testing Instructions
1 parent 736f250 commit d45a34b

File tree

8 files changed

+844
-39
lines changed

8 files changed

+844
-39
lines changed

docs/Configuring.md

Lines changed: 119 additions & 36 deletions
Large diffs are not rendered by default.

opentdf-dev.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,12 @@ server:
151151
allowcredentials: true
152152
# Sets the maximum age (in seconds) of a specific CORS preflight request
153153
maxage: 3600
154+
# Additive fields - append to base lists without replacing defaults
155+
# Use these to add custom values without having to copy all defaults
156+
# additionalmethods: []
157+
# additionalheaders:
158+
# - X-Custom-Header
159+
# additionalexposedheaders: []
154160
grpc:
155161
reflectionEnabled: true # Default is false
156162
# http:

service/internal/server/server.go

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,89 @@ type CORSConfig struct {
129129
AllowCredentials bool `mapstructure:"allowcredentials" json:"allowcredentials" default:"true"`
130130
MaxAge int `mapstructure:"maxage" json:"maxage" default:"3600"`
131131
Debug bool `mapstructure:"debug" json:"debug"`
132+
133+
// Additive fields - appended to base lists at runtime without replacing defaults
134+
AdditionalMethods []string `mapstructure:"additionalmethods" json:"additionalmethods"`
135+
AdditionalHeaders []string `mapstructure:"additionalheaders" json:"additionalheaders"`
136+
AdditionalExposedHeaders []string `mapstructure:"additionalexposedheaders" json:"additionalexposedheaders"`
137+
}
138+
139+
// mergeStringSlices combines base and additional slices, removing duplicates.
140+
// The order is: base items first, then additional items (preserving order within each).
141+
// Comparison is case-sensitive.
142+
func mergeStringSlices(base, additional []string) []string {
143+
if len(additional) == 0 {
144+
return base
145+
}
146+
if len(base) == 0 {
147+
return additional
148+
}
149+
150+
seen := make(map[string]struct{}, len(base)+len(additional))
151+
result := make([]string, 0, len(base)+len(additional))
152+
153+
for _, v := range base {
154+
if _, exists := seen[v]; !exists {
155+
seen[v] = struct{}{}
156+
result = append(result, v)
157+
}
158+
}
159+
for _, v := range additional {
160+
if _, exists := seen[v]; !exists {
161+
seen[v] = struct{}{}
162+
result = append(result, v)
163+
}
164+
}
165+
return result
166+
}
167+
168+
// mergeHeaderSlices combines base and additional HTTP header slices with case-insensitive
169+
// deduplication. HTTP headers are case-insensitive per RFC 7230, so "Authorization" and
170+
// "authorization" are treated as duplicates. The first occurrence's original casing is preserved.
171+
func mergeHeaderSlices(base, additional []string) []string {
172+
if len(additional) == 0 {
173+
return base
174+
}
175+
if len(base) == 0 {
176+
return additional
177+
}
178+
179+
// Use canonical header keys for case-insensitive comparison
180+
seen := make(map[string]struct{}, len(base)+len(additional))
181+
result := make([]string, 0, len(base)+len(additional))
182+
183+
for _, v := range base {
184+
canonical := textproto.CanonicalMIMEHeaderKey(v)
185+
if _, exists := seen[canonical]; !exists {
186+
seen[canonical] = struct{}{}
187+
result = append(result, v) // Preserve original casing
188+
}
189+
}
190+
for _, v := range additional {
191+
canonical := textproto.CanonicalMIMEHeaderKey(v)
192+
if _, exists := seen[canonical]; !exists {
193+
seen[canonical] = struct{}{}
194+
result = append(result, v) // Preserve original casing
195+
}
196+
}
197+
return result
198+
}
199+
200+
// EffectiveMethods returns AllowedMethods merged with AdditionalMethods.
201+
func (c CORSConfig) EffectiveMethods() []string {
202+
return mergeStringSlices(c.AllowedMethods, c.AdditionalMethods)
203+
}
204+
205+
// EffectiveHeaders returns AllowedHeaders merged with AdditionalHeaders.
206+
// Uses case-insensitive deduplication since HTTP headers are case-insensitive per RFC 7230.
207+
func (c CORSConfig) EffectiveHeaders() []string {
208+
return mergeHeaderSlices(c.AllowedHeaders, c.AdditionalHeaders)
209+
}
210+
211+
// EffectiveExposedHeaders returns ExposedHeaders merged with AdditionalExposedHeaders.
212+
// Uses case-insensitive deduplication since HTTP headers are case-insensitive per RFC 7230.
213+
func (c CORSConfig) EffectiveExposedHeaders() []string {
214+
return mergeHeaderSlices(c.ExposedHeaders, c.AdditionalExposedHeaders)
132215
}
133216

134217
type ConnectRPC struct {
@@ -314,6 +397,19 @@ func newHTTPServer(c Config, connectRPC http.Handler, originalGrpcGateway http.H
314397
// Note: The grpc-gateway handlers are getting chained together in reverse. So the last handler is the first to be called.
315398
// CORS
316399
if c.CORS.Enabled {
400+
// Compute effective values by merging base and additional lists
401+
effectiveMethods := c.CORS.EffectiveMethods()
402+
effectiveHeaders := c.CORS.EffectiveHeaders()
403+
effectiveExposed := c.CORS.EffectiveExposedHeaders()
404+
405+
// Log effective CORS config for operator visibility
406+
l.Info("CORS middleware enabled",
407+
slog.Any("allowed_origins", c.CORS.AllowedOrigins),
408+
slog.Any("effective_methods", effectiveMethods),
409+
slog.Any("effective_headers", effectiveHeaders),
410+
slog.Any("effective_exposed_headers", effectiveExposed),
411+
)
412+
317413
corsHandler := cors.New(cors.Options{
318414
AllowOriginFunc: func(_ *http.Request, origin string) bool {
319415
for _, allowedOrigin := range c.CORS.AllowedOrigins {
@@ -326,9 +422,9 @@ func newHTTPServer(c Config, connectRPC http.Handler, originalGrpcGateway http.H
326422
}
327423
return false
328424
},
329-
AllowedMethods: c.CORS.AllowedMethods,
330-
AllowedHeaders: c.CORS.AllowedHeaders,
331-
ExposedHeaders: c.CORS.ExposedHeaders,
425+
AllowedMethods: effectiveMethods,
426+
AllowedHeaders: effectiveHeaders,
427+
ExposedHeaders: effectiveExposed,
332428
AllowCredentials: c.CORS.AllowCredentials,
333429
MaxAge: c.CORS.MaxAge,
334430
Debug: c.CORS.Debug,

0 commit comments

Comments
 (0)