Skip to content

Commit 079eef3

Browse files
authored
feat: add HP_TRUSTED_PROXY_IPS env option (#53)
* feat: add HP_TRUSTED_PROXY_IPS env option Signed-off-by: Oleksander Piskun <[email protected]>
1 parent 766ba1c commit 079eef3

File tree

4 files changed

+65
-11
lines changed

4 files changed

+65
-11
lines changed

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ ENV HP_EXAPPS_ADDRESS="0.0.0.0:8780" \
3737
HP_TIMEOUT_CLIENT="30s" \
3838
HP_TIMEOUT_SERVER="1800s" \
3939
NC_INSTANCE_URL="" \
40+
HP_TRUSTED_PROXY_IPS="" \
4041
HP_LOG_LEVEL="warning"
4142

4243
# NOTE: We do NOT define HP_SHARED_KEY or HP_SHARED_KEY_FILE here

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,11 @@ HaRP is configured via several environment variables. Here are the key variables
136136
- `HP_EXAPPS_HTTPS_ADDRESS="0.0.0.0:8781"`
137137
- **Note:** Must be reachable by your reverse proxy.
138138

139+
- **`HP_TRUSTED_PROXY_IPS`**
140+
- **Description:** A comma-separated list of trusted reverse proxy IP addresses or CIDR ranges. When HaRP is behind another reverse proxy (like NGINX), set this to the IP of that proxy to allow HaRP to correctly identify the true client IP from `X-Forwarded-For` or `X-Real-IP` headers.
141+
- **Default:** `""` (disabled)
142+
- **Example:** `"172.18.0.0/16,127.0.0.1"`
143+
139144
- **`HP_FRP_ADDRESS`**
140145
- **Description:** IP:Port for the FRP (TCP) frontends.
141146
- **Default:** `HP_FRP_ADDRESS="0.0.0.0:8782"`
@@ -336,6 +341,7 @@ docker run \
336341
-e NC_INSTANCE_URL="http://nextcloud.local" \
337342
-e HP_LOG_LEVEL="debug" \
338343
-e HP_VERBOSE_START="1" \
344+
-e HP_TRUSTED_PROXY_IPS="192.168.0.0/16" \
339345
-v /var/run/docker.sock:/var/run/docker.sock \
340346
-v `pwd`/certs:/certs \
341347
--name appapi-harp -h appapi-harp \
@@ -346,6 +352,9 @@ docker run \
346352
-d nextcloud-appapi-harp:local
347353
```
348354

355+
> [!important]
356+
> Be mindful of checking and changing the environment variables `HP_SHARED_KEY`, `NC_INSTANCE_URL`, and `HP_TRUSTED_PROXY_IPS` in the above command to suit your environment and setup.
357+
349358
#### Debugging HaRP
350359

351360
##### One time initializing steps:

haproxy_agent.py

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@
4444
SPOA_AGENT = SpoaServer()
4545
DOCKER_API_HOST = "127.0.0.1"
4646

47+
TRUSTED_PROXIES_STR = os.environ.get("HP_TRUSTED_PROXY_IPS", "")
48+
TRUSTED_PROXIES = []
49+
if TRUSTED_PROXIES_STR:
50+
try:
51+
TRUSTED_PROXIES = [
52+
ipaddress.ip_network(proxy.strip()) for proxy in TRUSTED_PROXIES_STR.split(",") if proxy.strip()
53+
]
54+
LOGGER.info("Trusting reverse proxies for client IP detection: %s", [str(p) for p in TRUSTED_PROXIES])
55+
except ValueError as e:
56+
LOGGER.error(
57+
"Invalid value for HP_TRUSTED_PROXY_IPS: %s. Client IP detection from headers is disabled. "
58+
"The X-Forwarded-For and X-Real-IP headers will not be respected. "
59+
"This can lead to the outer proxy's IP being blocked during a bruteforce attempt instead of the actual client's IP.",
60+
e,
61+
)
62+
TRUSTED_PROXIES = []
63+
4764
###############################################################################
4865
# Definitions
4966
###############################################################################
@@ -155,6 +172,36 @@ class InstallCertificatesPayload(ExAppName):
155172
###############################################################################
156173

157174

175+
def get_true_client_ip(
176+
direct_ip: ipaddress.IPv4Address | ipaddress.IPv6Address, headers: dict[str, str]
177+
) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
178+
"""Determine the true client IP by inspecting headers from trusted proxies."""
179+
if not TRUSTED_PROXIES:
180+
return direct_ip
181+
182+
is_trusted = any(direct_ip in network for network in TRUSTED_PROXIES)
183+
if not is_trusted:
184+
return direct_ip
185+
186+
# The request is from a trusted proxy, so we can check the headers.
187+
# X-Forwarded-For can be a list: client, proxy1, proxy2. We want the first one.
188+
x_forwarded_for = headers.get("x-forwarded-for")
189+
if x_forwarded_for:
190+
true_ip_str = x_forwarded_for.split(",")[0].strip()
191+
try:
192+
return ipaddress.ip_address(true_ip_str)
193+
except ValueError:
194+
LOGGER.warning("Could not parse IP from X-Forwarded-For header: %s", true_ip_str)
195+
196+
x_real_ip = headers.get("x-real-ip")
197+
if x_real_ip:
198+
try:
199+
return ipaddress.ip_address(x_real_ip)
200+
except ValueError:
201+
LOGGER.warning("Could not parse IP from X-Real-IP header: %s", x_real_ip)
202+
return direct_ip # If headers are present but invalid, fall back to the direct IP of the proxy
203+
204+
158205
async def record_ip_failure(ip_address: str | IPv4Address | IPv6Address) -> None:
159206
"""Record a failed request attempt for this IP using BLACKLIST_CACHE."""
160207
ip_str = str(ip_address)
@@ -165,7 +212,7 @@ async def record_ip_failure(ip_address: str | IPv4Address | IPv6Address) -> None
165212
attempts = [ts for ts in attempts if now - ts < BLACKLIST_REQUEST_WINDOW]
166213
attempts.append(now)
167214
BLACKLIST_CACHE[ip_str] = attempts
168-
LOGGER.debug("Recorded failure for IP %s. Failures in window: %d", ip_str, len(attempts))
215+
LOGGER.warning("Recorded failure for IP %s. Failures in window: %d", ip_str, len(attempts))
169216

170217

171218
async def is_ip_banned(ip_address: str | IPv4Address | IPv6Address) -> bool:
@@ -219,8 +266,10 @@ async def get_session(pass_cookie: str) -> NcUser | None:
219266
async def exapps_msg(
220267
path: str, headers: str, client_ip: ipaddress.IPv4Address | ipaddress.IPv6Address, pass_cookie: str
221268
) -> AckPayload:
222-
client_ip_str = str(client_ip)
223269
reply = AckPayload()
270+
request_headers = parse_headers(headers)
271+
client_ip_str = str(get_true_client_ip(client_ip, request_headers))
272+
reply = reply.set_txn_var("true_client_ip", client_ip_str)
224273
LOGGER.debug("Incoming request to ExApp: path=%s, headers=%s, ip=%s", path, headers, client_ip_str)
225274

226275
# Check if the IP is banned based on failed attempts in BLACKLIST_CACHE.
@@ -238,8 +287,6 @@ async def exapps_msg(
238287
target_path = path.removeprefix(f"/exapps/{exapp_id}")
239288
reply = reply.set_txn_var("target_path", target_path)
240289

241-
request_headers = parse_headers(headers)
242-
243290
# Special handling for AppAPI requests
244291
if exapp_id == "app_api":
245292
return await handle_app_api_request(target_path, request_headers, client_ip_str, reply)
@@ -366,18 +413,15 @@ async def exapps_msg(
366413

367414

368415
@SPOA_AGENT.handler("exapps_response_status_msg")
369-
async def exapps_response_status_msg(
370-
status: int, client_ip: ipaddress.IPv4Address | ipaddress.IPv6Address, statuses_to_trigger_bp: str
371-
) -> AckPayload:
416+
async def exapps_response_status_msg(status: int, client_ip: str, statuses_to_trigger_bp: str) -> AckPayload:
372417
reply = AckPayload()
373418
if not statuses_to_trigger_bp:
374419
return reply.set_txn_var("bp_triggered", 0)
375420
statuses = json.loads(statuses_to_trigger_bp)
376421
if status not in statuses:
377422
return reply.set_txn_var("bp_triggered", 0)
378-
str_client_ip = str(client_ip)
379-
LOGGER.warning("Bruteforce protection(status=%s) triggered IP=%s.", status, str_client_ip)
380-
await record_ip_failure(str_client_ip)
423+
LOGGER.warning("Bruteforce protection(status=%s) triggered IP=%s.", status, client_ip)
424+
await record_ip_failure(client_ip)
381425
return reply.set_txn_var("bp_triggered", 1)
382426

383427

spoe-agent.conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,4 @@ spoe-agent exapps-agent
2626

2727
spoe-message exapps_response_status_msg
2828
event on-http-response
29-
args status=status client_ip=src statuses_to_trigger_bp=var(txn.exapps.statuses_to_trigger_bp)
29+
args status=status client_ip=var(txn.exapps.true_client_ip) statuses_to_trigger_bp=var(txn.exapps.statuses_to_trigger_bp)

0 commit comments

Comments
 (0)