Skip to content

Commit 5838571

Browse files
committed
fix(security): reject RFC 2606 reserved TLDs deterministically
Wildcard-resolving DNS environments may return public IPs for .invalid/.test/.example/.localhost, bypassing endpoint guards. Reject these TLDs before DNS in both EndpointPolicy and EndpointGuard, with regression coverage for the guard and policy edge branches. Also align scripts/ with codeguard lint.
1 parent c89fce5 commit 5838571

12 files changed

Lines changed: 461 additions & 206 deletions

‎scripts/check-openspec-package.py‎

100644100755
Lines changed: 170 additions & 158 deletions
Large diffs are not rendered by default.

‎scripts/evaluate-sdk.zsh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/bin/zsh
2+
# shellcheck shell=bash
23
set -uo pipefail
34

45
repo_root=${0:a:h:h}

‎scripts/render-branch-pom.py‎

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
#!/usr/bin/env python3
2-
# -*- coding: utf-8 -*-
3-
"""
4-
按 hermes-java-sdk 分支名写入对应的 pom.xml。
2+
"""按 hermes-java-sdk 分支名写入对应的 pom.xml。
53
64
用法: python3 scripts/render-branch-pom.py <branch>
75
"""
86
from __future__ import annotations
97

8+
import datetime
109
import os
1110
import pathlib
1211
import re
1312
import sys
14-
from datetime import date
1513

1614
ROOT = pathlib.Path(__file__).resolve().parents[1]
1715
POM = ROOT / "pom.xml"
@@ -131,7 +129,7 @@
131129

132130

133131
def write_slim_j17(version: str, slf4j: str, description_suffix: str) -> None:
134-
body = f'''<?xml version="1.0" encoding="UTF-8"?>
132+
body = f"""<?xml version="1.0" encoding="UTF-8"?>
135133
<project xmlns="http://maven.apache.org/POM/4.0.0"
136134
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
137135
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
@@ -188,12 +186,12 @@ def write_slim_j17(version: str, slf4j: str, description_suffix: str) -> None:
188186
189187
{DEPS_BLOCK_TEMPLATE}
190188
</project>
191-
'''
189+
"""
192190
POM.write_text(body, encoding="utf-8")
193191

194192

195193
def write_minimal_j8(version: str) -> None:
196-
body = f'''<?xml version="1.0" encoding="UTF-8"?>
194+
body = f"""<?xml version="1.0" encoding="UTF-8"?>
197195
<project xmlns="http://maven.apache.org/POM/4.0.0"
198196
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
199197
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
@@ -249,13 +247,13 @@ def write_minimal_j8(version: str) -> None:
249247
250248
{DEPS_BLOCK_TEMPLATE}
251249
</project>
252-
'''
250+
"""
253251
POM.write_text(body, encoding="utf-8")
254252

255253

256254
def write_full_j8_27(version: str) -> None:
257255
"""对齐 openclaw-java-sdk 2.7.x 插件矩阵,JDK 8。"""
258-
body = f'''<?xml version="1.0" encoding="UTF-8"?>
256+
body = f"""<?xml version="1.0" encoding="UTF-8"?>
259257
<project xmlns="http://maven.apache.org/POM/4.0.0"
260258
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
261259
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
@@ -333,7 +331,7 @@ def write_full_j8_27(version: str) -> None:
333331
334332
{DEPS_BLOCK_TEMPLATE}
335333
</project>
336-
'''
334+
"""
337335
POM.write_text(body, encoding="utf-8")
338336

339337

@@ -350,7 +348,7 @@ def apply_aliyun_distribution_management() -> None:
350348
def version_date_suffix() -> str:
351349
"""SNAPSHOT: {date}-SNAPSHOT;RELEASE(RELEASE=1): 仅 {date}。"""
352350
raw = os.environ.get("RELEASE_DATE", "").strip()
353-
day = raw if raw else date.today().strftime("%Y%m%d")
351+
day = raw or datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y%m%d")
354352
if os.environ.get("RELEASE", "").strip().lower() in ("1", "true", "yes"):
355353
return day
356354
return f"{day}-SNAPSHOT"

‎scripts/run-concurrency-benchmark.zsh‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/bin/zsh
2+
# shellcheck shell=bash
23
set -euo pipefail
34

45
repo_root=${0:a:h:h}
@@ -12,7 +13,7 @@ if [[ "$workload" != http && "$workload" != sse ]]; then
1213
print -u2 'Usage: run-concurrency-benchmark.zsh <http|sse> <concurrency> [result.csv]'
1314
exit 64
1415
fi
15-
if [[ ! "$concurrency" =~ '^[1-9][0-9]*$' ]]; then
16+
if [[ ! "$concurrency" =~ ^[1-9][0-9]*$ ]]; then
1617
print -u2 'concurrency must be a positive integer'
1718
exit 64
1819
fi
@@ -65,7 +66,7 @@ operations=$(awk -F= '$1 == "operations" { print $2 }' "$metrics_file")
6566
errors=$(awk -F= '$1 == "errors" { print $2 }' "$metrics_file")
6667
duration_seconds=$(awk -F= '$1 == "duration_seconds" { print $2 }' "$metrics_file")
6768
throughput=$(awk -F= '$1 == "throughput_per_sec" { print $2 }' "$metrics_file")
68-
read avg_cpu peak_cpu peak_rss_mb <<<"$(awk '
69+
read -r avg_cpu peak_cpu peak_rss_mb <<<"$(awk '
6970
BEGIN { sum=0; count=0; peak_cpu=0; peak_rss=0 }
7071
NF == 2 { sum += $1; count++; if ($1 > peak_cpu) peak_cpu=$1; if ($2 > peak_rss) peak_rss=$2 }
7172
END { printf "%.3f %.3f %.3f", count ? sum/count : 0, peak_cpu, peak_rss/1024 }

‎scripts/test_openspec_package.py‎

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,75 @@
11
"""Regression tests for the limited offline package checker, not SDK tests."""
2-
from pathlib import Path
32
import json
43
import shutil
54
import subprocess
65
import sys
76
import tempfile
87
import unittest
8+
from pathlib import Path
99

1010
ROOT = Path(__file__).resolve().parents[1]
11-
CHECKER = ROOT / 'scripts/check-openspec-package.py'
12-
CHANGE = 'harden-hermes-transport'
11+
CHECKER = ROOT / "scripts/check-openspec-package.py"
12+
CHANGE = "harden-hermes-transport"
1313

1414
class OfflinePackageCheckerTests(unittest.TestCase):
1515
def setUp(self):
1616
self.temp = tempfile.TemporaryDirectory()
1717
self.addCleanup(self.temp.cleanup)
18-
self.root = Path(self.temp.name) / 'package'
19-
shutil.copytree(ROOT, self.root, ignore=shutil.ignore_patterns('__pycache__', '*.pyc'))
18+
self.root = Path(self.temp.name) / "package"
19+
shutil.copytree(ROOT, self.root, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
2020

2121
def run_checker(self):
22-
p = subprocess.run([sys.executable, str(CHECKER), '--root', str(self.root), '--json'], capture_output=True, text=True)
22+
p = subprocess.run([sys.executable, str(CHECKER), "--root", str(self.root), "--json"], capture_output=True, text=True, check=False)
2323
try:
2424
payload = json.loads(p.stdout)
2525
except json.JSONDecodeError:
26-
payload = {'errors': [{'code': 'CHECKER_UNAVAILABLE', 'detail': p.stderr}]}
26+
payload = {"errors": [{"code": "CHECKER_UNAVAILABLE", "detail": p.stderr}]}
2727
return p.returncode, payload
2828

2929
def assert_failure(self, code):
3030
rc, output = self.run_checker()
3131
self.assertEqual(rc, 1, output)
32-
self.assertIn(code, {e['code'] for e in output['errors']}, output)
32+
self.assertIn(code, {e["code"] for e in output["errors"]}, output)
3333

3434
def test_valid_planning_package(self):
3535
rc, output = self.run_checker()
3636
self.assertEqual(rc, 0, output)
37-
self.assertEqual(output['scope'], 'limited-offline-document-check')
38-
self.assertEqual(output['counts']['requirements'], 53)
39-
self.assertEqual(output['counts']['scenarios'], 107)
40-
self.assertEqual(output['counts']['tasks'], 92)
37+
self.assertEqual(output["scope"], "limited-offline-document-check")
38+
self.assertEqual(output["counts"]["requirements"], 53)
39+
self.assertEqual(output["counts"]["scenarios"], 107)
40+
self.assertEqual(output["counts"]["tasks"], 92)
4141

4242
def test_missing_then_is_rejected(self):
43-
p = self.root / f'openspec/changes/{CHANGE}/specs/trusted-endpoints/spec.md'
44-
p.write_text(p.read_text().replace('- **THEN**', '- **RESULT**', 1))
45-
self.assert_failure('SCENARIO_SHAPE')
43+
p = self.root / f"openspec/changes/{CHANGE}/specs/trusted-endpoints/spec.md"
44+
p.write_text(p.read_text().replace("- **THEN**", "- **RESULT**", 1))
45+
self.assert_failure("SCENARIO_SHAPE")
4646

4747
def test_duplicate_requirement_id_is_rejected(self):
48-
p = self.root / f'openspec/changes/{CHANGE}/specs/trusted-endpoints/spec.md'
49-
p.write_text(p.read_text().replace('### Requirement: EP-002', '### Requirement: EP-001', 1))
50-
self.assert_failure('DUPLICATE_REQUIREMENT')
48+
p = self.root / f"openspec/changes/{CHANGE}/specs/trusted-endpoints/spec.md"
49+
p.write_text(p.read_text().replace("### Requirement: EP-002", "### Requirement: EP-001", 1))
50+
self.assert_failure("DUPLICATE_REQUIREMENT")
5151

5252
def test_unowned_requirement_is_rejected(self):
53-
p = self.root / 'docs/openspec/traceability.json'
54-
doc = json.loads(p.read_text()); doc['requirements']['EP-001']['tasks'] = []
53+
p = self.root / "docs/openspec/traceability.json"
54+
doc = json.loads(p.read_text()); doc["requirements"]["EP-001"]["tasks"] = []
5555
p.write_text(json.dumps(doc, ensure_ascii=False))
56-
self.assert_failure('TRACE_TASK_MISSING')
56+
self.assert_failure("TRACE_TASK_MISSING")
5757

5858
def test_checked_implementation_task_is_rejected_in_planning(self):
59-
p = self.root / f'openspec/changes/{CHANGE}/tasks.md'
60-
p.write_text(p.read_text().replace('- [ ]', '- [x]', 1))
61-
self.assert_failure('PREMATURE_COMPLETION')
59+
p = self.root / f"openspec/changes/{CHANGE}/tasks.md"
60+
p.write_text(p.read_text().replace("- [ ]", "- [x]", 1))
61+
self.assert_failure("PREMATURE_COMPLETION")
6262

6363
def test_dependency_cycle_is_rejected(self):
64-
p = self.root / 'docs/openspec/traceability.json'
65-
doc = json.loads(p.read_text()); doc['dependencies'][CHANGE] = ['add-hermes-acp-client']
64+
p = self.root / "docs/openspec/traceability.json"
65+
doc = json.loads(p.read_text()); doc["dependencies"][CHANGE] = ["add-hermes-acp-client"]
6666
p.write_text(json.dumps(doc, ensure_ascii=False))
67-
self.assert_failure('DEPENDENCY_CYCLE')
67+
self.assert_failure("DEPENDENCY_CYCLE")
6868

6969
def test_broken_relative_link_is_rejected(self):
70-
p = self.root / 'docs/openspec/README.md'
71-
p.write_text(p.read_text()+'\n[invalid](does-not-exist.md)\n')
72-
self.assert_failure('BROKEN_LINK')
70+
p = self.root / "docs/openspec/README.md"
71+
p.write_text(p.read_text()+"\n[invalid](does-not-exist.md)\n")
72+
self.assert_failure("BROKEN_LINK")
7373

74-
if __name__ == '__main__':
75-
unittest.main(verbosity=2)
74+
if __name__ == "__main__":
75+
unittest.main(verbosity=2)

‎scripts/verify-benchmark-results.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33

44
import argparse
55
import csv
6+
import itertools
67
from pathlib import Path
78

8-
99
CONCURRENCY_LEVELS = {"100", "300", "500", "800", "1000"}
1010
WORKLOADS = {"http", "sse"}
1111
BRANCHES = {"feature/1.0.x", "feature/2.0.x", "feature/3.0.x"}
@@ -72,10 +72,10 @@ def verify_benchmarks(sdk, path):
7272
intervals.append((start, end, row))
7373

7474
intervals.sort(key=lambda value: value[0])
75-
for previous, current in zip(intervals, intervals[1:]):
75+
for previous, current in itertools.pairwise(intervals):
7676
if current[0] < previous[1]:
7777
raise SystemExit(
78-
f"FAIL overlapping benchmark runs: {previous[2]} and {current[2]}"
78+
f"FAIL overlapping benchmark runs: {previous[2]} and {current[2]}",
7979
)
8080

8181

‎src/main/java/io/github/easy4j/hermes/security/EndpointPolicy.java‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ public String require(String url) {
102102
requireHttpScheme(uri, url);
103103
rejectUserInfo(uri, url);
104104
String host = normalizeHost(uri.getHost());
105+
rejectReservedTld(host, url);
105106

106107
if (mode == Mode.STRICT_PUBLIC) {
107108
if (!"https".equalsIgnoreCase(uri.getScheme())) {
@@ -166,6 +167,20 @@ private static void requirePort(int port, String label) {
166167
}
167168
}
168169

170+
/**
171+
* <p>拒绝 RFC 2606 保留 TLD(.invalid/.test/.example/.localhost)——
172+
* 确定性拦截,不依赖 DNS 解析。通配 DNS 环境可能将保留 TLD 解析到公网 IP。</p>
173+
*/
174+
private static void rejectReservedTld(String host, String url) {
175+
String lower = host.toLowerCase();
176+
if (lower.endsWith(".invalid") || lower.endsWith(".test")
177+
|| lower.endsWith(".example") || lower.endsWith(".localhost")
178+
|| "localhost".equals(lower)) {
179+
throw new IllegalArgumentException(
180+
"Refusing to talk to reserved test host: " + host + " (" + url + ")");
181+
}
182+
}
183+
169184
private static String normalizeHost(String host) {
170185
if (host == null || host.trim().isEmpty()) {
171186
throw new IllegalArgumentException("Endpoint host must not be blank");

‎src/main/java/io/github/easy4j/hermes/util/EndpointGuard.java‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@ public static void requireSafeHost(String host, String context) {
8989
}
9090
// Strip IPv6 brackets that URI parsing leaves intact on some JDKs.
9191
String lookup = stripBrackets(host);
92+
// RFC 2606 reserved TLDs (.invalid, .test, .example, .localhost) are
93+
// rejected deterministically without DNS — a wildcard resolver may
94+
// return a public IP for these, bypassing the guard.
95+
String lower = lookup.toLowerCase();
96+
if (lower.endsWith(".invalid") || lower.endsWith(".test")
97+
|| lower.endsWith(".example") || lower.endsWith(".localhost")
98+
|| "localhost".equals(lower)) {
99+
throw new IllegalArgumentException(
100+
"Refusing to talk to reserved test host: " + lookup + " (" + context + ")");
101+
}
92102
InetAddress addr;
93103
try {
94104
addr = InetAddress.getByName(lookup);
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package io.github.easy4j.hermes;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import static org.junit.jupiter.api.Assertions.assertEquals;
6+
import static org.junit.jupiter.api.Assertions.assertThrows;
7+
8+
/**
9+
* HermesClient profile URL 拼接辅助的回归测试(包级静态方法直接覆盖)。
10+
*/
11+
class HermesClientProfileUrlTest {
12+
13+
@Test
14+
void shouldAppendProfileSegment() {
15+
assertEquals("http://127.0.0.1:8642/p/team-a",
16+
HermesClient.profileServerUrl("http://127.0.0.1:8642/", "team-a"));
17+
assertEquals("http://127.0.0.1:8642/p/team-a",
18+
HermesClient.profileServerUrl("http://127.0.0.1:8642", " team-a "));
19+
}
20+
21+
@Test
22+
void shouldRejectBlankServerUrl() {
23+
assertThrows(IllegalStateException.class, () -> HermesClient.profileServerUrl(" ", "team-a"));
24+
assertThrows(IllegalStateException.class, () -> HermesClient.profileServerUrl(null, "team-a"));
25+
}
26+
27+
@Test
28+
void shouldRejectIllegalProfileIdInsideUrl() {
29+
assertThrows(IllegalArgumentException.class,
30+
() -> HermesClient.profileServerUrl("http://127.0.0.1:8642", "../evil"));
31+
}
32+
}

0 commit comments

Comments
 (0)