Skip to content

Commit a105aea

Browse files
committed
SITES-49845: Adds configurable forwarding of client IP and other request headers to Commerce
1 parent 3adfe50 commit a105aea

4 files changed

Lines changed: 550 additions & 0 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2+
~ Copyright 2026 Adobe
3+
~
4+
~ Licensed under the Apache License, Version 2.0 (the "License");
5+
~ you may not use this file except in compliance with the License.
6+
~ You may obtain a copy of the License at
7+
~
8+
~ http://www.apache.org/licenses/LICENSE-2.0
9+
~
10+
~ Unless required by applicable law or agreed to in writing, software
11+
~ distributed under the License is distributed on an "AS IS" BASIS,
12+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
~ See the License for the specific language governing permissions and
14+
~ limitations under the License.
15+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
16+
package com.adobe.cq.commerce.core.components.internal.client;
17+
18+
import org.osgi.service.metatype.annotations.AttributeDefinition;
19+
import org.osgi.service.metatype.annotations.ObjectClassDefinition;
20+
21+
/**
22+
* Single configuration covering all forwarding of incoming request headers to the outbound Commerce GraphQL
23+
* request: a master switch, an arbitrary list of headers forwarded as-is, and a dedicated client IP section with
24+
* its own switch and fields (source header/pattern/outbound name), since the client IP needs more than a plain
25+
* name to be forwarded correctly. Every header forwarded through this configuration, generic or client IP, is
26+
* excluded from the GraphQL response cache key, since it carries per-request metadata that does not influence the
27+
* response. Headers on the internal denylist (Authorization, Cookie, Host, Content-Length, etc.) are never
28+
* forwarded, even if configured here.
29+
*/
30+
@ObjectClassDefinition(name = "CIF Forwarded Request Headers Configuration")
31+
public @interface ForwardedHeadersConfig {
32+
33+
@AttributeDefinition(
34+
name = "Enabled",
35+
description = "Master switch for all header forwarding configured below. Disable to turn everything off without "
36+
+ "clearing the individual fields.")
37+
boolean enabled() default false;
38+
39+
@AttributeDefinition(
40+
name = "Forwarded header names",
41+
description = "Names of incoming request headers whose current value should be forwarded as-is, under the same name, on "
42+
+ "the outbound GraphQL request to Commerce (e.g. a tracing/correlation id header).")
43+
String[] forwardedHeaderNames() default {};
44+
45+
@AttributeDefinition(
46+
name = "Enable client IP forwarding",
47+
description = "Forwards the end-user IP using the dedicated fields below, in addition to any generic headers above. Only "
48+
+ "enable this once the CDN/dispatcher in front of AEM is confirmed to set the source header from the actual client "
49+
+ "connection, not from an unvalidated client-supplied value.")
50+
boolean clientIpEnabled() default false;
51+
52+
@AttributeDefinition(
53+
name = "Client IP header name",
54+
description = "Incoming request header set by the CDN/edge/dispatcher that carries the original client IP. Defaults to "
55+
+ "the standard 'X-Forwarded-For', already populated by the AEMaaCS managed CDN and by common on-premise "
56+
+ "dispatcher/reverse-proxy setups. Different CDNs may use a dedicated header instead, e.g. 'CF-Connecting-IP' "
57+
+ "(Cloudflare), 'True-Client-IP' (Akamai), 'Fastly-Client-IP' (Fastly). The reserved value 'REMOTE_ADDR' reads the "
58+
+ "direct TCP connection IP instead of a header: only correct when AEM is reached with no proxy/CDN/dispatcher in "
59+
+ "between (e.g. local development), since behind any proxy this would instead resolve to that proxy's own IP.")
60+
String clientIpHeaderName() default "X-Forwarded-For";
61+
62+
@AttributeDefinition(
63+
name = "Client IP outbound header name",
64+
description = "Header name used to forward the client IP on the outbound Commerce request, so it never collides with a "
65+
+ "header name already used for another purpose between AEM and Commerce.")
66+
String clientIpOutboundHeaderName() default "X-Adobe-Client-IP";
67+
68+
@AttributeDefinition(
69+
name = "Client IP value extraction pattern",
70+
description = "Regex with a single capturing group used to extract the client IP from the header value above. The "
71+
+ "default pattern takes the leftmost token, which works for a plain single-IP header (e.g. 'CF-Connecting-IP') as "
72+
+ "well as a multi-hop 'X-Forwarded-For' chain ('client, proxy1, proxy2'). Use 'for=\"?\\[?([0-9a-fA-F:.]+)' for the "
73+
+ "standards-based 'Forwarded' header (RFC 7239), or '^([0-9a-fA-F:.]+):\\d+$' to strip a trailing port, e.g. "
74+
+ "CloudFront's 'CloudFront-Viewer-Address'.")
75+
String clientIpHeaderValuePattern() default "^\\s*([0-9a-fA-F:.]+)";
76+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2+
~ Copyright 2026 Adobe
3+
~
4+
~ Licensed under the Apache License, Version 2.0 (the "License");
5+
~ you may not use this file except in compliance with the License.
6+
~ You may obtain a copy of the License at
7+
~
8+
~ http://www.apache.org/licenses/LICENSE-2.0
9+
~
10+
~ Unless required by applicable law or agreed to in writing, software
11+
~ distributed under the License is distributed on an "AS IS" BASIS,
12+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
~ See the License for the specific language governing permissions and
14+
~ limitations under the License.
15+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
16+
package com.adobe.cq.commerce.core.components.internal.client;
17+
18+
import java.util.Arrays;
19+
import java.util.Collections;
20+
import java.util.LinkedHashSet;
21+
import java.util.Set;
22+
import java.util.regex.Pattern;
23+
import java.util.regex.PatternSyntaxException;
24+
25+
import org.apache.commons.lang3.StringUtils;
26+
import org.osgi.service.component.annotations.Activate;
27+
import org.osgi.service.component.annotations.Component;
28+
import org.osgi.service.metatype.annotations.Designate;
29+
import org.slf4j.Logger;
30+
import org.slf4j.LoggerFactory;
31+
32+
/**
33+
* Holds the {@link ForwardedHeadersConfig} in a form ready to use by {@link MagentoGraphqlClientImpl}: the client
34+
* IP pattern is pre-compiled once here rather than on every request, and an invalid pattern disables client IP
35+
* forwarding instead of failing GraphQL requests at runtime.
36+
*/
37+
@Component(service = ForwardedHeadersConfigService.class)
38+
@Designate(ocd = ForwardedHeadersConfig.class)
39+
public class ForwardedHeadersConfigService {
40+
41+
/**
42+
* Reserved {@link ForwardedHeadersConfig#clientIpHeaderName()} value: read the direct TCP connection IP
43+
* instead of a header. Only correct with no proxy/CDN/dispatcher in front of AEM (e.g. local development).
44+
*/
45+
static final String REMOTE_ADDR = "REMOTE_ADDR";
46+
47+
private static final Logger LOGGER = LoggerFactory.getLogger(ForwardedHeadersConfigService.class);
48+
49+
private boolean enabled;
50+
private Set<String> forwardedHeaderNames = Collections.emptySet();
51+
private boolean clientIpEnabled;
52+
private String clientIpHeaderName;
53+
private String clientIpOutboundHeaderName;
54+
private Pattern clientIpHeaderValuePattern;
55+
56+
@Activate
57+
protected void activate(ForwardedHeadersConfig config) {
58+
this.enabled = config.enabled();
59+
60+
String[] configuredNames = config.forwardedHeaderNames();
61+
this.forwardedHeaderNames = configuredNames != null && configuredNames.length > 0
62+
? new LinkedHashSet<>(Arrays.asList(configuredNames))
63+
: Collections.emptySet();
64+
65+
this.clientIpEnabled = config.clientIpEnabled();
66+
this.clientIpHeaderName = config.clientIpHeaderName();
67+
this.clientIpOutboundHeaderName = StringUtils.isNotBlank(config.clientIpOutboundHeaderName())
68+
? config.clientIpOutboundHeaderName()
69+
: config.clientIpHeaderName();
70+
71+
try {
72+
this.clientIpHeaderValuePattern = Pattern.compile(config.clientIpHeaderValuePattern());
73+
} catch (PatternSyntaxException e) {
74+
LOGGER.error("Invalid client IP header value pattern '{}', client IP forwarding is disabled",
75+
config.clientIpHeaderValuePattern(), e);
76+
this.clientIpEnabled = false;
77+
}
78+
}
79+
80+
public boolean isEnabled() {
81+
return enabled;
82+
}
83+
84+
public Set<String> getForwardedHeaderNames() {
85+
return forwardedHeaderNames;
86+
}
87+
88+
public boolean isClientIpEnabled() {
89+
return clientIpEnabled;
90+
}
91+
92+
public String getClientIpHeaderName() {
93+
return clientIpHeaderName;
94+
}
95+
96+
public String getClientIpOutboundHeaderName() {
97+
return clientIpOutboundHeaderName;
98+
}
99+
100+
public Pattern getClientIpHeaderValuePattern() {
101+
return clientIpHeaderValuePattern;
102+
}
103+
}

bundles/core/src/main/java/com/adobe/cq/commerce/core/components/internal/client/MagentoGraphqlClientImpl.java

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,16 @@
2121
import java.util.Calendar;
2222
import java.util.Collections;
2323
import java.util.HashMap;
24+
import java.util.HashSet;
2425
import java.util.List;
2526
import java.util.Locale;
2627
import java.util.Map;
2728
import java.util.Objects;
2829
import java.util.Set;
2930
import java.util.TimeZone;
3031
import java.util.concurrent.atomic.AtomicLong;
32+
import java.util.regex.Matcher;
33+
import java.util.regex.Pattern;
3134
import java.util.stream.Collectors;
3235

3336
import javax.annotation.PostConstruct;
@@ -41,6 +44,7 @@
4144
import org.apache.sling.api.resource.ResourceResolver;
4245
import org.apache.sling.models.annotations.Model;
4346
import org.apache.sling.models.annotations.injectorspecific.InjectionStrategy;
47+
import org.apache.sling.models.annotations.injectorspecific.OSGiService;
4448
import org.apache.sling.models.annotations.injectorspecific.ScriptVariable;
4549
import org.slf4j.Logger;
4650
import org.slf4j.LoggerFactory;
@@ -92,6 +96,11 @@ public class MagentoGraphqlClientImpl implements MagentoGraphqlClient {
9296
private Resource resource;
9397
@ScriptVariable(injectionStrategy = InjectionStrategy.OPTIONAL)
9498
private Page currentPage;
99+
// Admin-configured forwarding of incoming request headers (generic headers, plus a dedicated client IP
100+
// section) to the outbound Commerce request. Which header/source and how to parse the client IP differs per
101+
// CDN/dispatcher (AEMaaCS vs. on-premise, and across CDN vendors), hence configuration, not hardcoded here.
102+
@OSGiService(injectionStrategy = InjectionStrategy.OPTIONAL)
103+
private ForwardedHeadersConfigService forwardedHeadersConfigService;
95104

96105
private GraphqlClient graphqlClient;
97106
private RequestOptions requestOptions;
@@ -208,6 +217,20 @@ private void initModel(Resource resource, Page page, SlingHttpServletRequest req
208217
httpMethod = HttpMethod.POST;
209218
}
210219

220+
// Headers carrying per-request metadata (client IP, forwarded tracing/correlation ids, ...) must not
221+
// influence the GraphQL response cache key, since they do not affect the response itself.
222+
Set<String> nonCacheKeyHeaderNames = new HashSet<>();
223+
224+
if (request != null && forwardedHeadersConfigService != null && forwardedHeadersConfigService.isEnabled()) {
225+
// Checked separately from the generic forwarded headers below, since the client IP needs its own
226+
// source pattern and outbound name rather than a plain same-name pass-through.
227+
if (forwardedHeadersConfigService.isClientIpEnabled()) {
228+
applyClientIpForwarding(request, forwardedHeadersConfigService, headers, nonCacheKeyHeaderNames);
229+
}
230+
231+
applyGenericHeaderForwarding(request, forwardedHeadersConfigService, headers, nonCacheKeyHeaderNames);
232+
}
233+
211234
this.httpHeaders = headers;
212235
// In certain situations resource.getResourceType() returns an enforced resource type.
213236
// We prefer the resource type of the component proxy for the cache name.
@@ -218,6 +241,7 @@ private void initModel(Resource resource, Page page, SlingHttpServletRequest req
218241
.withCacheName(cacheName)
219242
.withDataFetchingPolicy(DataFetchingPolicy.CACHE_FIRST))
220243
.withHeaders(headers.size() > 0 ? headers : null)
244+
.withNonCacheKeyHeaderNames(nonCacheKeyHeaderNames)
221245
.withHttpMethod(httpMethod);
222246

223247
if (request != null) {
@@ -361,6 +385,85 @@ private static List<Header> getCustomHttpHeaders(ComponentsConfiguration configu
361385
return headers;
362386
}
363387

388+
/**
389+
* Forwards the client IP using the dedicated source header/pattern/outbound name from
390+
* {@link ForwardedHeadersConfig}, since the client IP needs more than a plain same-name pass-through: a source
391+
* that may be {@code REMOTE_ADDR}, a value extraction pattern, and typically a different outbound name.
392+
*/
393+
private static void applyClientIpForwarding(SlingHttpServletRequest request, ForwardedHeadersConfigService config,
394+
List<Header> headers, Set<String> nonCacheKeyHeaderNames) {
395+
String outboundHeaderName = config.getClientIpOutboundHeaderName();
396+
String value = readRequestValue(request, config.getClientIpHeaderName(), config.getClientIpHeaderValuePattern());
397+
addForwardedHeader(outboundHeaderName, value, headers, nonCacheKeyHeaderNames);
398+
}
399+
400+
/**
401+
* Forwards each configured generic header (e.g. a tracing/correlation id) as-is, under the same name, with no
402+
* value extraction pattern. Kept separate from client IP forwarding above so each stays simple to read.
403+
*/
404+
private static void applyGenericHeaderForwarding(SlingHttpServletRequest request, ForwardedHeadersConfigService config,
405+
List<Header> headers, Set<String> nonCacheKeyHeaderNames) {
406+
for (String headerName : config.getForwardedHeaderNames()) {
407+
String value = readRequestValue(request, headerName, null);
408+
addForwardedHeader(headerName, value, headers, nonCacheKeyHeaderNames);
409+
}
410+
}
411+
412+
/**
413+
* Adds {@code value} to {@code headers} under {@code outboundHeaderName} and marks it as excluded from the
414+
* response cache key, unless the value is missing, the name is denylisted, or a header with that name is
415+
* already present (a statically configured header always takes precedence).
416+
*/
417+
private static void addForwardedHeader(String outboundHeaderName, String value, List<Header> headers,
418+
Set<String> nonCacheKeyHeaderNames) {
419+
if (value == null) {
420+
return;
421+
}
422+
if (DENIED_HEADERS.contains(outboundHeaderName.toLowerCase(Locale.ROOT))) {
423+
LOGGER.warn("Ignoring denylisted outbound header '{}' configured for forwarding", outboundHeaderName);
424+
return;
425+
}
426+
if (headers.stream().noneMatch(header -> header.getName().equalsIgnoreCase(outboundHeaderName))) {
427+
headers.add(new BasicHeader(outboundHeaderName, value));
428+
nonCacheKeyHeaderNames.add(outboundHeaderName);
429+
}
430+
}
431+
432+
/**
433+
* Reads the value to forward from the configured source: either the direct TCP connection IP
434+
* ({@code REMOTE_ADDR}), or the named incoming header, optionally parsed with a pattern. A {@code null}
435+
* pattern means the header's raw value is forwarded as-is. Which source to read, and how to parse it, is
436+
* configuration ({@link ForwardedHeadersConfig}) rather than hardcoded here, since different CDNs/dispatchers
437+
* in front of AEM (AEMaaCS vs. on-premise, and across CDN vendors) expose values like the client IP
438+
* differently.
439+
*/
440+
private static String readRequestValue(SlingHttpServletRequest request, String headerName, Pattern headerValuePattern) {
441+
if (StringUtils.isBlank(headerName)) {
442+
return null;
443+
}
444+
445+
if (ForwardedHeadersConfigService.REMOTE_ADDR.equalsIgnoreCase(headerName)) {
446+
return StringUtils.trimToNull(request.getRemoteAddr());
447+
}
448+
449+
String headerValue = StringUtils.trimToNull(request.getHeader(headerName));
450+
if (headerValue == null) {
451+
return null;
452+
}
453+
454+
if (headerValuePattern == null) {
455+
return headerValue;
456+
}
457+
458+
Matcher matcher = headerValuePattern.matcher(headerValue);
459+
if (matcher.find()) {
460+
return matcher.group(1);
461+
}
462+
463+
LOGGER.warn("Could not extract a value from header '{}' using the configured pattern", headerName);
464+
return null;
465+
}
466+
364467
private static Long getTimeWarpEpoch(SlingHttpServletRequest request) {
365468
String timeWarp = request.getParameter("timewarp");
366469
if (timeWarp == null) {

0 commit comments

Comments
 (0)