Skip to content

Commit d5b0083

Browse files
fix: proxy direct APNs HTTP2 sessions (#74905)
Summary: - This PR routes direct APNs HTTP/2 sends through an APNs allowlisted managed-proxy CONNECT wrapper, adds APNs proxy validation/docs/guardrails, and expands regression and live-test coverage. - Reproducibility: yes. source-reproducible: current main `sendApnsRequest()` still uses raw `http2.connect(au ... nly covers HTTP/global-agent/Undici hooks. I did not run a live APNs reproduction in this read-only review. Automerge notes: - PR branch already contained follow-up commit before automerge: test: guard raw HTTP2 APNs connections - PR branch already contained follow-up commit before automerge: test: guard raw HTTP2 with OpenGrep - PR branch already contained follow-up commit before automerge: lint: ban raw HTTP2 imports - PR branch already contained follow-up commit before automerge: fix: use managed proxy state for APNs - PR branch already contained follow-up commit before automerge: test: exercise APNs active proxy state - PR branch already contained follow-up commit before automerge: fix: reject conflicting managed proxy activation Validation: - ClawSweeper review passed for head dab7c86. - Required merge gates passed before the squash merge. Prepared head SHA: dab7c86 Review: #74905 (comment) Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
1 parent 5efbb30 commit d5b0083

30 files changed

Lines changed: 2156 additions & 86 deletions

.github/workflows/openclaw-live-and-e2e-checks-reusable.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,7 @@ jobs:
409409
add_profile_suite native-live-src-gateway-profiles-xai "full"
410410
add_profile_suite native-live-src-gateway-profiles-zai "full"
411411
add_profile_suite native-live-src-gateway-backends "stable full"
412+
add_profile_suite native-live-src-infra "stable full"
412413
add_profile_suite native-live-test "stable full"
413414
add_profile_suite native-live-extensions-l-n "full"
414415
add_profile_suite native-live-extensions-moonshot "full"
@@ -1989,6 +1990,12 @@ jobs:
19891990
timeout_minutes: 90
19901991
profile_env_only: false
19911992
profiles: stable full
1993+
- suite_id: native-live-src-infra
1994+
label: Native live infra
1995+
command: OPENCLAW_LIVE_APNS_REACHABILITY=1 node .release-harness/scripts/test-live-shard.mjs native-live-src-infra
1996+
timeout_minutes: 45
1997+
profile_env_only: false
1998+
profiles: stable full
19921999
- suite_id: native-live-test
19932000
label: Native live test harnesses
19942001
command: node .release-harness/scripts/test-live-shard.mjs native-live-test

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ Docs: https://docs.openclaw.ai
278278
- Slack: collapse routine Socket Mode pong-timeout reconnects into one OpenClaw reconnect line and suppress the duplicate Slack SDK pong warning.
279279
- Gateway/diagnostics: abort-drain embedded runs after an extended no-progress stall so a single dead session no longer leaves queued Discord/channel turns blocked behind repeated `recovery=none` liveness warnings.
280280
- Plugins/ClawHub: accept the live artifact resolver `kind`/`sha256` field names alongside the typed `artifactKind`/`artifactSha256` form so `clawhub:` installs of npm-pack and legacy ZIP packages no longer miss downloadable artifacts. Thanks @romneyda.
281+
- Direct APNs: route direct HTTP/2 delivery through the active managed proxy with redacted proxy diagnostics, so push requests honor configured egress controls and `openclaw proxy validate --apns-reachable` can prove APNs is reachable through the proxy before deployment. (#74905) Thanks @jesse-merhi.
281282
- Control UI/Sessions: avoid full `sessions.list` reloads for chat-turn `sessions.changed` payloads, so large session stores no longer add multi-second delays while chat responses are being delivered. (#76676) Thanks @VACInc.
282283
- Gateway/watch: run `doctor --fix --non-interactive` once and retry when the dev Gateway child exits during startup, so stale local plugin install/config state does not leave the tmux watch session disappearing without a repair attempt.
283284
- Doctor/Telegram: warn when selected Telegram quote replies can suppress `streaming.preview.toolProgress`, and document the `replyToMode` trade-off without changing runtime delivery. Fixes #73487. Thanks @GodsBoy.

docs/cli/proxy.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ captured blobs, and purge local capture data.
2323
```bash
2424
openclaw proxy start [--host <host>] [--port <port>]
2525
openclaw proxy run [--host <host>] [--port <port>] -- <cmd...>
26-
openclaw proxy validate [--json] [--proxy-url <url>] [--allowed-url <url>] [--denied-url <url>] [--timeout-ms <ms>]
26+
openclaw proxy validate [--json] [--proxy-url <url>] [--allowed-url <url>] [--denied-url <url>] [--apns-reachable] [--apns-authority <url>] [--timeout-ms <ms>]
2727
openclaw proxy coverage
2828
openclaw proxy sessions [--limit <count>]
2929
openclaw proxy query --preset <name> [--session <id>]
@@ -40,14 +40,19 @@ before changing config. By default it verifies that a public destination succeed
4040
through the proxy and that the proxy cannot reach a temporary loopback canary.
4141
Custom denied destinations are fail-closed: HTTP responses and ambiguous
4242
transport failures both fail unless you can verify a deployment-specific denial
43-
signal separately.
43+
signal separately. Add `--apns-reachable` to also open an APNs HTTP/2 CONNECT
44+
tunnel through the proxy and confirm sandbox APNs responds; the probe uses an
45+
intentionally invalid provider token, so an APNs `403 InvalidProviderToken`
46+
response is a successful reachability signal.
4447

4548
Options:
4649

4750
- `--json`: print machine-readable JSON.
4851
- `--proxy-url <url>`: validate this proxy URL instead of config or env.
4952
- `--allowed-url <url>`: add a destination expected to succeed through the proxy. Repeat to check multiple destinations.
5053
- `--denied-url <url>`: add a destination expected to be blocked by the proxy. Repeat to check multiple destinations.
54+
- `--apns-reachable`: also verify sandbox APNs HTTP/2 is reachable through the proxy.
55+
- `--apns-authority <url>`: APNs authority to probe with `--apns-reachable` (`https://api.sandbox.push.apple.com` by default; production is `https://api.push.apple.com`).
5156
- `--timeout-ms <ms>`: per-request timeout in milliseconds.
5257

5358
See [Network Proxy](/security/network-proxy) for deployment guidance and denial

docs/help/testing-live.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,15 @@ Notes:
203203
- The live CLI-backend smoke now exercises the same end-to-end flow for Claude, Codex, and Gemini: text turn, image classification turn, then MCP `cron` tool call verified through the gateway CLI.
204204
- Claude's default smoke also patches the session from Sonnet to Opus and verifies the resumed session still remembers an earlier note.
205205

206+
## Live: APNs HTTP/2 proxy reachability
207+
208+
- Test: `src/infra/push-apns-http2.live.test.ts`
209+
- Goal: tunnel through a local HTTP CONNECT proxy to Apple's sandbox APNs endpoint, send the APNs HTTP/2 validation request, and assert Apple's real `403 InvalidProviderToken` response comes back through the proxy path.
210+
- Enable:
211+
- `OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_APNS_REACHABILITY=1 pnpm test:live src/infra/push-apns-http2.live.test.ts`
212+
- Optional timeout:
213+
- `OPENCLAW_LIVE_APNS_TIMEOUT_MS=30000`
214+
206215
## Live: ACP bind smoke (`/acp spawn ... --bind here`)
207216

208217
- Test: `src/gateway/gateway-acp-bind.live.test.ts`

docs/security/network-proxy.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ Validate the proxy from the same host, container, or service account that runs O
139139
openclaw proxy validate --proxy-url http://127.0.0.1:3128
140140
```
141141

142-
By default, when no custom destinations are provided, the command checks that `https://example.com/` succeeds and starts a temporary loopback canary that the proxy must not reach. The default denied check passes when the proxy returns a non-2xx denial response or blocks the canary with a transport failure; it fails if a successful response reaches the canary. If no proxy is enabled and configured, validation reports a config problem; use `--proxy-url` for a one-off preflight before changing config. Use `--allowed-url` and `--denied-url` to test deployment-specific expectations. Custom denied destinations are fail-closed: any HTTP response means the destination was reachable through the proxy, and any transport error is reported as inconclusive because OpenClaw cannot prove the proxy blocked a reachable origin. On validation failure, the command exits with code 1.
142+
By default, when no custom destinations are provided, the command checks that `https://example.com/` succeeds and starts a temporary loopback canary that the proxy must not reach. The default denied check passes when the proxy returns a non-2xx denial response or blocks the canary with a transport failure; it fails if a successful response reaches the canary. If no proxy is enabled and configured, validation reports a config problem; use `--proxy-url` for a one-off preflight before changing config. Use `--allowed-url` and `--denied-url` to test deployment-specific expectations. Add `--apns-reachable` to also verify direct APNs HTTP/2 delivery can open a CONNECT tunnel through the proxy and receive a sandbox APNs response; the probe uses an intentionally invalid provider token, so `403 InvalidProviderToken` is expected and counts as reachable. Custom denied destinations are fail-closed: any HTTP response means the destination was reachable through the proxy, and any transport error is reported as inconclusive because OpenClaw cannot prove the proxy blocked a reachable origin. On validation failure, the command exits with code 1.
143143

144144
Use `--json` for automation. The JSON output contains the overall result, the effective proxy config source, any config errors, and each destination check. Proxy URL credentials are redacted in text and JSON output:
145145

@@ -158,6 +158,12 @@ Use `--json` for automation. The JSON output contains the overall result, the ef
158158
"url": "https://example.com/",
159159
"ok": true,
160160
"status": 200
161+
},
162+
{
163+
"kind": "apns",
164+
"url": "https://api.sandbox.push.apple.com",
165+
"ok": true,
166+
"status": 403
161167
}
162168
]
163169
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1420,12 +1420,13 @@
14201420
"lint:plugins:no-monolithic-plugin-sdk-entry-imports": "node --import tsx scripts/check-no-monolithic-plugin-sdk-entry-imports.ts",
14211421
"lint:plugins:no-register-http-handler": "node scripts/check-no-register-http-handler.mjs",
14221422
"lint:plugins:plugin-sdk-subpaths-exported": "node scripts/check-plugin-sdk-subpath-exports.mjs",
1423-
"lint:scripts": "pnpm lint:docker-e2e && node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.scripts.json scripts",
1423+
"lint:scripts": "pnpm lint:docker-e2e && pnpm lint:tmp:no-raw-http2-imports && node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.scripts.json scripts",
14241424
"lint:swift": "swiftlint lint --config config/swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
14251425
"lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs",
14261426
"lint:tmp:dynamic-import-warts": "node scripts/check-dynamic-import-warts.mjs",
14271427
"lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs",
14281428
"lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs",
1429+
"lint:tmp:no-raw-http2-imports": "node scripts/check-no-raw-http2-imports.mjs",
14291430
"lint:tmp:tsgo-core-boundary": "node scripts/check-tsgo-core-boundary.mjs",
14301431
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
14311432
"lint:web-fetch-provider-boundaries": "node scripts/check-web-fetch-provider-boundaries.mjs",
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
const SOURCE_ROOTS = ["src", "extensions"];
4+
const DEFAULT_SKIPPED_DIR_NAMES = new Set(["node_modules", "dist", "coverage", ".generated"]);
5+
6+
function isCodeFile(filePath) {
7+
if (filePath.endsWith(".d.ts")) {
8+
return false;
9+
}
10+
return /\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(filePath);
11+
}
12+
13+
function collectFilesSync(rootDir, options) {
14+
const skipDirNames = options.skipDirNames ?? DEFAULT_SKIPPED_DIR_NAMES;
15+
const files = [];
16+
const stack = [rootDir];
17+
18+
while (stack.length > 0) {
19+
const current = stack.pop();
20+
if (!current) {
21+
continue;
22+
}
23+
let entries = [];
24+
try {
25+
entries = fs.readdirSync(current, { withFileTypes: true });
26+
} catch {
27+
continue;
28+
}
29+
for (const entry of entries) {
30+
const fullPath = path.join(current, entry.name);
31+
if (entry.isDirectory()) {
32+
if (!skipDirNames.has(entry.name)) {
33+
stack.push(fullPath);
34+
}
35+
continue;
36+
}
37+
if (entry.isFile() && options.includeFile(fullPath)) {
38+
files.push(fullPath);
39+
}
40+
}
41+
}
42+
43+
return files;
44+
}
45+
46+
function toPosixPath(filePath) {
47+
return filePath.replaceAll("\\", "/");
48+
}
49+
50+
const FORBIDDEN_HTTP2_MODULES = new Set(["node:http2", "http2"]);
51+
const ALLOWED_PRODUCTION_FILES = new Set(["src/infra/push-apns-http2.ts"]);
52+
53+
function isTestFile(relativePath) {
54+
return (
55+
/(?:^|\/)(?:test|test-fixtures)\//u.test(relativePath) ||
56+
/\.test\.[cm]?[jt]sx?$/u.test(relativePath)
57+
);
58+
}
59+
60+
function lineNumberForOffset(content, offset) {
61+
return content.slice(0, offset).split(/\r?\n/u).length;
62+
}
63+
64+
function collectHttp2ImportOffenders(filePath) {
65+
const relativePath = toPosixPath(path.relative(process.cwd(), filePath));
66+
if (ALLOWED_PRODUCTION_FILES.has(relativePath) || isTestFile(relativePath)) {
67+
return [];
68+
}
69+
70+
const content = fs.readFileSync(filePath, "utf8");
71+
const offenders = [];
72+
const patterns = [
73+
/\bimport\s+(?:type\s+)?[\s\S]*?\bfrom\s*["']([^"']+)["']/gu,
74+
/\bexport\s+(?:type\s+)?[\s\S]*?\bfrom\s*["']([^"']+)["']/gu,
75+
/\bimport\s*\(\s*["']([^"']+)["']\s*\)/gu,
76+
/\brequire\s*\(\s*["']([^"']+)["']\s*\)/gu,
77+
];
78+
79+
for (const pattern of patterns) {
80+
for (const match of content.matchAll(pattern)) {
81+
const specifier = match[1];
82+
if (specifier && FORBIDDEN_HTTP2_MODULES.has(specifier)) {
83+
offenders.push({
84+
file: relativePath,
85+
line: lineNumberForOffset(content, match.index ?? 0),
86+
specifier,
87+
});
88+
}
89+
}
90+
}
91+
92+
return offenders;
93+
}
94+
95+
function collectSourceFiles() {
96+
return SOURCE_ROOTS.flatMap((root) =>
97+
collectFilesSync(path.join(process.cwd(), root), {
98+
includeFile: isCodeFile,
99+
}),
100+
);
101+
}
102+
103+
function main() {
104+
const offenders = collectSourceFiles().flatMap(collectHttp2ImportOffenders);
105+
if (offenders.length === 0) {
106+
console.log("OK: raw node:http2 imports stay behind the APNs proxy wrapper.");
107+
return;
108+
}
109+
110+
console.error("Raw node:http2 imports are only allowed in src/infra/push-apns-http2.ts.");
111+
for (const offender of offenders.toSorted(
112+
(a, b) => a.file.localeCompare(b.file) || a.line - b.line,
113+
)) {
114+
console.error(`- ${offender.file}:${offender.line} imports ${offender.specifier}`);
115+
}
116+
console.error("Use connectApnsHttp2Session() so APNs HTTP/2 honors managed proxy policy.");
117+
process.exit(1);
118+
}
119+
120+
main();

scripts/run-additional-boundary-checks.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const BOUNDARY_CHECKS = [
99
["lint:tmp:channel-agnostic-boundaries", "pnpm", ["run", "lint:tmp:channel-agnostic-boundaries"]],
1010
["lint:tmp:tsgo-core-boundary", "pnpm", ["run", "lint:tmp:tsgo-core-boundary"]],
1111
["lint:tmp:no-raw-channel-fetch", "pnpm", ["run", "lint:tmp:no-raw-channel-fetch"]],
12+
["lint:tmp:no-raw-http2-imports", "pnpm", ["run", "lint:tmp:no-raw-http2-imports"]],
1213
["lint:agent:ingress-owner", "pnpm", ["run", "lint:agent:ingress-owner"]],
1314
[
1415
"lint:plugins:no-register-http-handler",

scripts/test-live-shard.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const RELEASE_LIVE_TEST_SHARDS = Object.freeze([
1111
"native-live-src-gateway-core",
1212
"native-live-src-gateway-profiles",
1313
"native-live-src-gateway-backends",
14+
"native-live-src-infra",
1415
"native-live-test",
1516
"native-live-extensions-a-k",
1617
"native-live-extensions-l-n",
@@ -154,6 +155,8 @@ export function selectLiveShardFiles(shard, files = collectAllLiveTestFiles()) {
154155
return files.filter(isGatewayProfilesLiveTest);
155156
case "native-live-src-gateway-backends":
156157
return files.filter(isGatewayBackendLiveTest);
158+
case "native-live-src-infra":
159+
return files.filter((file) => file.startsWith("src/infra/"));
157160
case "native-live-test":
158161
return files.filter((file) => file.startsWith("test/"));
159162
case "native-live-extensions-a-k":

security/opengrep/precise.yml

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
# Auto-generated by security/opengrep/compile-rules.mjs.
44
# DO NOT EDIT BY HAND. Re-run the compile script after editing source rules.
55
#
6-
# Source rules dir: <unknown>
7-
# Generated at : 2026-04-29T07:10:35.427Z
8-
# Rule count : 147
6+
# Source rules dir: security/opengrep/rules/openclaw-policy
7+
# Generated at : 2026-04-30T09:09:41.198Z
8+
# Rule count : 148
99
rules:
1010
- id: ghsa-25gx-x37c-7pph.openclaw-novnc-x11vnc-missing-auth
1111
message: x11vnc starts without VNC authentication; avoid -nopw and require password auth when exposing noVNC observer access.
@@ -4976,3 +4976,37 @@ rules:
49764976
- pattern-not-inside: |
49774977
import { resolvePathWithinRoot, ... } from "$X";
49784978
...
4979+
- id: openclaw-policy-raw-http2-connect.no-raw-http2-connect
4980+
languages:
4981+
- typescript
4982+
- javascript
4983+
severity: ERROR
4984+
message: Use connectApnsHttp2Session() from src/infra/push-apns-http2.ts instead of raw http2.connect() so APNs HTTP/2 honors managed proxy policy.
4985+
metadata:
4986+
advisory-id: OPENCLAW-POLICY-RAW-HTTP2-CONNECT
4987+
advisory-url: https://github.com/openclaw/openclaw/pull/74905
4988+
cwe:
4989+
- CWE-441
4990+
category: security
4991+
confidence: HIGH
4992+
detector-bucket: precise
4993+
source-rule-id: no-raw-http2-connect
4994+
source-file: security/opengrep/rules/openclaw-policy/no-raw-http2-connect.yml
4995+
paths:
4996+
include:
4997+
- src/**/*.ts
4998+
- src/**/*.mts
4999+
- src/**/*.js
5000+
- src/**/*.mjs
5001+
- extensions/**/*.ts
5002+
- extensions/**/*.mts
5003+
- extensions/**/*.js
5004+
- extensions/**/*.mjs
5005+
exclude:
5006+
- src/infra/push-apns-http2.ts
5007+
- "**/*.test.ts"
5008+
- "**/*.test.mts"
5009+
- "**/*.test.js"
5010+
- "**/*.test.mjs"
5011+
patterns:
5012+
- pattern: http2.connect(...)

0 commit comments

Comments
 (0)