From 0dcab9bfb32bcec6d9ae821ca2129d395700f821 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sun, 19 Jul 2026 06:49:02 +0200 Subject: [PATCH 01/13] security: minimize public health diagnostics --- Sources/CodexAccountSwitcher/main.swift | 15 ++++-- .../SetupStatusTests.swift | 8 ++- docs/SECURITY.md | 2 +- gateway/codex_phone_gateway.py | 24 +++++++-- gateway/test_codex_phone_gateway.py | 54 ++++++++++++++++++- ops/agents/escalations/security-scan.md | 3 +- 6 files changed, 93 insertions(+), 13 deletions(-) diff --git a/Sources/CodexAccountSwitcher/main.swift b/Sources/CodexAccountSwitcher/main.swift index 116b3c58..62e5254d 100644 --- a/Sources/CodexAccountSwitcher/main.swift +++ b/Sources/CodexAccountSwitcher/main.swift @@ -2569,8 +2569,12 @@ enum SwitchError: LocalizedError { } enum CodePilotGatewayHealthProbe { - static func request() -> URLRequest { - URLRequest(url: URL(string: "http://127.0.0.1:18790/api/health")!) + static func request(token: String?) -> URLRequest { + var request = URLRequest(url: URL(string: "http://127.0.0.1:18790/api/health")!) + if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + return request } static func requirement(from data: Data?) -> CodePilotSetupRequirement { @@ -2725,7 +2729,12 @@ struct CodePilotSetupStatus { } private static func gatewayHealthStatus() -> CodePilotGatewayHealthStatus { - CodePilotGatewayHealthProbe.status(from: synchronousData(for: CodePilotGatewayHealthProbe.request())) + let tokenPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex-account-switcher/phone-gateway-token") + let token = try? String(contentsOf: tokenPath, encoding: .utf8) + return CodePilotGatewayHealthProbe.status( + from: synchronousData(for: CodePilotGatewayHealthProbe.request(token: token)) + ) } private static func synchronousData(for request: URLRequest) -> Data? { diff --git a/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift b/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift index f92353c7..6fdbf924 100644 --- a/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift +++ b/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift @@ -79,7 +79,7 @@ final class SetupStatusTests: XCTestCase { } func testGatewayHealthProbeUsesPublicHealthWithoutBearerToken() { - let request = CodePilotGatewayHealthProbe.request() + let request = CodePilotGatewayHealthProbe.request(token: nil) XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) let runningPayload = #"{"gateway":{"running":true}}"#.data(using: .utf8) @@ -90,6 +90,12 @@ final class SetupStatusTests: XCTestCase { XCTAssertEqual(CodePilotGatewayHealthProbe.requirement(from: Data("not json".utf8)), .gatewayStopped) } + func testGatewayHealthProbeUsesTokenForPrivateDiagnostics() { + let request = CodePilotGatewayHealthProbe.request(token: " private-token\n") + + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer private-token") + } + func testGatewayHealthProbeReportsNotificationReadiness() { let configuredPayload = #"{"gateway":{"running":true},"notifications":{"configured":true}}"#.data(using: .utf8) XCTAssertEqual( diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 023e3609..8436666d 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -25,7 +25,7 @@ Recommended: The iOS client refuses non-HTTPS gateway URLs except for `localhost`, `::1`, and syntactically valid `127.x.x.x` loopback addresses. DNS names that merely begin with `127.` are not loopback exceptions and still require HTTPS. The client also rejects gateway URLs containing user information, query strings, or fragments, and credential-bearing requests do not follow cross-origin redirects. -The gateway health response is intentionally public-safe, but it is still operational metadata. Treat the gateway URL and token as private. +The unauthenticated gateway health response contains only liveness and version data. Account, job, notification, and remote-access diagnostics are returned only when the request includes the iOS connection token. Treat the gateway URL and token as private. Do not place infrastructure or connector management tokens in `phone-gateway.env`. The gateway service deliberately does not load `SUPABASE_ACCESS_TOKEN`, and gateway-launched Codex processes scrub gateway-only credentials from their environment. Configure sensitive connectors in a separately trusted local Codex session instead of exposing their bearer tokens to remotely initiated turns. diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index c8f3babb..7964b9f9 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -2303,6 +2303,14 @@ def app_server_auth_status(self) -> dict: } def public_health(self) -> dict: + return { + "gateway": { + "running": True, + "version": read_marker(DEFAULT_SWITCHER_HOME / "gateway-version", "dev"), + }, + } + + def diagnostic_health(self) -> dict: remote_desktop_status = {"available": False} remote_desktop = self.remote_desktop_gateway if remote_desktop is not None: @@ -4888,9 +4896,7 @@ def end_headers(self): super().end_headers() def authenticate(self) -> bool: - expected = self.state().token - header = self.headers.get("authorization", "") - if secrets.compare_digest(header, f"Bearer {expected}"): + if self.is_authenticated(): return True json_error( self, @@ -4901,6 +4907,11 @@ def authenticate(self) -> bool: ) return False + def is_authenticated(self) -> bool: + expected = self.state().token + header = self.headers.get("authorization", "") + return secrets.compare_digest(header, f"Bearer {expected}") + def do_GET(self): parsed = urllib.parse.urlparse(self.path) path = parsed.path.rstrip("/") or "/" @@ -4931,7 +4942,12 @@ def do_GET(self): return if path == "/health" or path == "/api/health": - json_response(self, 200, self.state().public_health()) + payload = ( + self.state().diagnostic_health() + if self.is_authenticated() + else self.state().public_health() + ) + json_response(self, 200, payload) return if not self.authenticate(): diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index f6bc3e8c..43bb49ce 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -1254,7 +1254,7 @@ def process_factory(arguments, **_kwargs): finally: gateway.JOBS.pop(job_id, None) - def test_public_health_exposes_safe_gateway_status(self): + def test_public_health_exposes_only_non_identifying_liveness(self): with tempfile.TemporaryDirectory() as tmp: original_switcher_home = gateway.DEFAULT_SWITCHER_HOME switcher_home = Path(tmp) / "switcher" @@ -1271,12 +1271,30 @@ def test_public_health_exposes_safe_gateway_status(self): gateway.DEFAULT_SWITCHER_HOME = original_switcher_home self.assertTrue(health["gateway"]["running"]) + self.assertEqual(set(health), {"gateway"}) + self.assertNotIn("secret-token", json.dumps(health)) + + def test_diagnostic_health_retains_authenticated_setup_status(self): + with tempfile.TemporaryDirectory() as tmp: + original_switcher_home = gateway.DEFAULT_SWITCHER_HOME + switcher_home = Path(tmp) / "switcher" + codex_home = Path(tmp) / "codex" + try: + gateway.DEFAULT_SWITCHER_HOME = switcher_home + switcher_home.mkdir() + codex_home.mkdir() + (switcher_home / "active-account.txt").write_text("main", encoding="utf-8") + state = GatewayState(codex_home, "secret-token", Path("/missing-codex"), False) + + health = state.diagnostic_health() + finally: + gateway.DEFAULT_SWITCHER_HOME = original_switcher_home + self.assertEqual(health["accounts"]["active"], "main") self.assertIn("auth", health["accounts"]) self.assertIn("notifications", health) self.assertIn("remoteDesktop", health) self.assertIn("localWeb", health) - self.assertNotIn("secret-token", json.dumps(health)) def test_health_endpoint_is_available_without_bearer_token(self): with tempfile.TemporaryDirectory() as tmp: @@ -1309,8 +1327,40 @@ def test_health_endpoint_is_available_without_bearer_token(self): self.assertEqual(status, 200) self.assertTrue(payload["gateway"]["running"]) + self.assertEqual(set(payload), {"gateway"}) self.assertNotIn("secret-token", json.dumps(payload)) + def test_health_endpoint_returns_diagnostics_with_bearer_token(self): + with tempfile.TemporaryDirectory() as tmp: + original_switcher_home = gateway.DEFAULT_SWITCHER_HOME + switcher_home = Path(tmp) / "switcher" + codex_home = Path(tmp) / "codex" + server = None + thread = None + try: + gateway.DEFAULT_SWITCHER_HOME = switcher_home + switcher_home.mkdir() + codex_home.mkdir() + state = GatewayState(codex_home, "secret-token", Path("/missing-codex"), False) + server = gateway.GatewayServer(("127.0.0.1", 0), state) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + url = f"http://127.0.0.1:{server.server_address[1]}/api/health" + request = urllib.request.Request(url, headers={"Authorization": "Bearer secret-token"}) + with urllib.request.urlopen(request, timeout=2) as response: + payload = json.loads(response.read().decode("utf-8")) + finally: + if server is not None: + server.shutdown() + server.server_close() + if thread is not None: + thread.join(timeout=2) + gateway.DEFAULT_SWITCHER_HOME = original_switcher_home + + self.assertIn("accounts", payload) + self.assertIn("notifications", payload) + def test_error_payload_has_stable_code_message_and_recovery(self): payload = gateway.error_payload( "local_web_invalid_target", diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index 32488095..f1889f53 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -6,11 +6,10 @@ Detailed security findings are intentionally not stored in the public repository Before public launch, maintainer coordination is still required for: -- Reviewing and resolving the open GitHub secret-scanning alert through the private security channel, including revocation or rotation where applicable. - A confidential review before any currently disabled privileged remote-access feature is re-enabled. - A credential and edge-access architecture review for the remotely reachable gateway. - Sanitizing historical private identifiers and commit metadata, followed by coordinated clone migration. -- Enabling repository dependency alerts and reviewing the initial results. +- Enabling dependency alerts and security updates, plus non-provider secret patterns and validity checks, then reviewing the initial results. - Shipping pending credential-storage changes through an authorized build channel and rotating affected beta credentials. Keep fail-closed controls enabled until the corresponding private review is complete. Do not copy private findings into public issues or pull requests. From 6600e0a6b9bc3e2248c0142748d763fd693944a2 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sun, 19 Jul 2026 07:58:44 +0200 Subject: [PATCH 02/13] security: reduce gateway fingerprinting --- gateway/codex_phone_gateway.py | 5 ++++- gateway/test_codex_phone_gateway.py | 2 ++ ops/agents/escalations/security-scan.md | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index 7964b9f9..b3d9b630 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -4881,7 +4881,10 @@ def run_turn_exec( class Handler(BaseHTTPRequestHandler): - server_version = "CodexPhoneGateway/0.1" + server_version = "CodePilotGateway" + + def version_string(self) -> str: + return self.server_version def state(self) -> GatewayState: return self.server.state diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index 43bb49ce..78f70c12 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -1316,6 +1316,7 @@ def test_health_endpoint_is_available_without_bearer_token(self): url = f"http://127.0.0.1:{server.server_address[1]}/api/health" with urllib.request.urlopen(url, timeout=2) as response: status = response.status + server_header = response.headers.get("Server") payload = json.loads(response.read().decode("utf-8")) finally: if server is not None: @@ -1326,6 +1327,7 @@ def test_health_endpoint_is_available_without_bearer_token(self): gateway.DEFAULT_SWITCHER_HOME = original_switcher_home self.assertEqual(status, 200) + self.assertEqual(server_header, "CodePilotGateway") self.assertTrue(payload["gateway"]["running"]) self.assertEqual(set(payload), {"gateway"}) self.assertNotIn("secret-token", json.dumps(payload)) diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index f1889f53..7a8f6199 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -9,7 +9,7 @@ Before public launch, maintainer coordination is still required for: - A confidential review before any currently disabled privileged remote-access feature is re-enabled. - A credential and edge-access architecture review for the remotely reachable gateway. - Sanitizing historical private identifiers and commit metadata, followed by coordinated clone migration. -- Enabling dependency alerts and security updates, plus non-provider secret patterns and validity checks, then reviewing the initial results. +- Enabling dependency alerts and security updates, code scanning, plus non-provider secret patterns and validity checks, then reviewing the initial results. - Shipping pending credential-storage changes through an authorized build channel and rotating affected beta credentials. Keep fail-closed controls enabled until the corresponding private review is complete. Do not copy private findings into public issues or pull requests. From 49b52c9c04e1718f492cc8dcf7bb50b888ff458c Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sun, 19 Jul 2026 08:56:57 +0200 Subject: [PATCH 03/13] security: harden public gateway and scanning --- .github/dependabot.yml | 19 +++++++++ .github/workflows/codeql.yml | 54 +++++++++++++++++++++++++ docs/SECURITY.md | 2 +- gateway/codex_phone_gateway.py | 7 ++-- gateway/test_codex_phone_gateway.py | 30 +++++++++++++- ops/agents/escalations/security-scan.md | 4 +- 6 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..41edd987 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: swift + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: bundler + directory: /ios/CodexPhone + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..9811eea1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,54 @@ +name: CodeQL + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "23 4 * * 2" + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ${{ matrix.os }} + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: actions + os: ubuntu-latest + build-mode: none + - language: python + os: ubuntu-latest + build-mode: none + - language: ruby + os: ubuntu-latest + build-mode: none + - language: swift + os: macos-latest + build-mode: autobuild + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Autobuild + if: matrix.build-mode == 'autobuild' + uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + + - name: Analyze + uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 8436666d..c9b1d55e 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -25,7 +25,7 @@ Recommended: The iOS client refuses non-HTTPS gateway URLs except for `localhost`, `::1`, and syntactically valid `127.x.x.x` loopback addresses. DNS names that merely begin with `127.` are not loopback exceptions and still require HTTPS. The client also rejects gateway URLs containing user information, query strings, or fragments, and credential-bearing requests do not follow cross-origin redirects. -The unauthenticated gateway health response contains only liveness and version data. Account, job, notification, and remote-access diagnostics are returned only when the request includes the iOS connection token. Treat the gateway URL and token as private. +The unauthenticated gateway health response contains only a liveness flag. Version, account, job, notification, and remote-access diagnostics are returned only when the request includes the iOS connection token. Treat the gateway URL and token as private. Do not place infrastructure or connector management tokens in `phone-gateway.env`. The gateway service deliberately does not load `SUPABASE_ACCESS_TOKEN`, and gateway-launched Codex processes scrub gateway-only credentials from their environment. Configure sensitive connectors in a separately trusted local Codex session instead of exposing their bearer tokens to remotely initiated turns. diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index b3d9b630..fb25daa3 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -2306,7 +2306,6 @@ def public_health(self) -> dict: return { "gateway": { "running": True, - "version": read_marker(DEFAULT_SWITCHER_HOME / "gateway-version", "dev"), }, } @@ -4931,15 +4930,15 @@ def do_GET(self): self, 404, "local_web_unavailable", - str(exc), + "Local web session not found or expired", "Open the localhost link again from CodePilot; local web sessions expire after a short time.", ) - except Exception as exc: + except Exception: json_error( self, 502, "local_web_unavailable", - str(exc), + "The selected local web server is unavailable", "Make sure the local web server is running on the Mac, then reopen the link.", ) return diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index 78f70c12..52c2802d 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -6,9 +6,9 @@ import subprocess import tempfile import threading +import urllib.error import urllib.request import unittest -import urllib.request from unittest import mock from pathlib import Path @@ -1272,6 +1272,7 @@ def test_public_health_exposes_only_non_identifying_liveness(self): self.assertTrue(health["gateway"]["running"]) self.assertEqual(set(health), {"gateway"}) + self.assertEqual(set(health["gateway"]), {"running"}) self.assertNotIn("secret-token", json.dumps(health)) def test_diagnostic_health_retains_authenticated_setup_status(self): @@ -1330,8 +1331,35 @@ def test_health_endpoint_is_available_without_bearer_token(self): self.assertEqual(server_header, "CodePilotGateway") self.assertTrue(payload["gateway"]["running"]) self.assertEqual(set(payload), {"gateway"}) + self.assertEqual(set(payload["gateway"]), {"running"}) self.assertNotIn("secret-token", json.dumps(payload)) + def test_local_web_endpoint_hides_backend_failure_details(self): + state = GatewayState( + Path("/tmp/codex"), + "secret-token", + Path("/missing-codex"), + False, + local_web_fetcher=mock.Mock(side_effect=RuntimeError("private backend detail")), + ) + session = state.start_local_web_session("http://localhost:3000/") + server = gateway.GatewayServer(("127.0.0.1", 0), state) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{server.server_address[1]}{session['path']}" + with self.assertRaises(urllib.error.HTTPError) as raised: + urllib.request.urlopen(url, timeout=2) + payload = json.loads(raised.exception.read().decode("utf-8")) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + self.assertEqual(raised.exception.code, 502) + self.assertEqual(payload["error"]["message"], "The selected local web server is unavailable") + self.assertNotIn("private backend detail", json.dumps(payload)) + def test_health_endpoint_returns_diagnostics_with_bearer_token(self): with tempfile.TemporaryDirectory() as tmp: original_switcher_home = gateway.DEFAULT_SWITCHER_HOME diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index 7a8f6199..2e8a37a7 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -9,7 +9,9 @@ Before public launch, maintainer coordination is still required for: - A confidential review before any currently disabled privileged remote-access feature is re-enabled. - A credential and edge-access architecture review for the remotely reachable gateway. - Sanitizing historical private identifiers and commit metadata, followed by coordinated clone migration. -- Enabling dependency alerts and security updates, code scanning, plus non-provider secret patterns and validity checks, then reviewing the initial results. +- Protecting the default branch with required reviews and passing checks. +- Enabling private vulnerability reporting before inviting public security reports. +- Enabling dependency alerts and security updates, plus non-provider secret patterns and validity checks, then reviewing the initial Dependabot, CodeQL, and secret-scanning results. - Shipping pending credential-storage changes through an authorized build channel and rotating affected beta credentials. Keep fail-closed controls enabled until the corresponding private review is complete. Do not copy private findings into public issues or pull requests. From f3bd9f433266050aa401020b476f3075325f7019 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sun, 19 Jul 2026 10:03:12 +0200 Subject: [PATCH 04/13] security: require encrypted gateway connections --- ios/CodexPhone/CodexPhone/CodexPhoneApp.swift | 16 ++++++++-------- .../CodexPhone/GatewayCredentials.swift | 1 + .../CodexPhoneTests/RemoteDesktopTests.swift | 18 +++++++++++++++--- ops/agents/escalations/security-scan.md | 2 ++ 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift index a8d8667c..26b03d86 100644 --- a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift +++ b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift @@ -1899,12 +1899,7 @@ func isMacLocalWebURL(_ url: URL) -> Bool { } func gatewayRootURL(from rawValue: String) throws -> URL { - let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard let url = URL(string: trimmed), - let scheme = url.scheme?.lowercased(), - ["http", "https"].contains(scheme), - let host = url.host, - !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + guard let url = GatewayEndpoint.baseURL(from: rawValue) else { throw GatewayError.invalidURL } return url @@ -1919,7 +1914,11 @@ func gatewaySetupValidationMessage(url rawURL: String, token rawToken: String, c if trimmedToken.isEmpty { return "Enter the iOS connection token from the Mac setup screen." } - guard let url = try? gatewayRootURL(from: trimmedURL) else { + guard let url = URL(string: trimmedURL), + let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme), + let rawHost = url.host, + !rawHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "Gateway URL must start with http:// or https:// and include a host." } if url.user != nil @@ -1929,11 +1928,12 @@ func gatewaySetupValidationMessage(url rawURL: String, token rawToken: String, c || url.fragment != nil { return "Gateway URL must be the server address only, without credentials, a path, query, or fragment." } - let scheme = url.scheme?.lowercased() let host = url.host?.lowercased() ?? "" switch connectionKind { case .local where host == "localhost" || host == "127.0.0.1" || host == "::1": return "Same Network needs the Mac's LAN address, not localhost or 127.0.0.1." + case .local where scheme != "https": + return "Same Network connections must use https:// so the iOS connection token is encrypted." case .cloudflare where host == "localhost" || host == "127.0.0.1" || host == "::1": return "Cloudflare needs the public tunnel URL from the Mac setup screen, not localhost or 127.0.0.1." case .cloudflare where IPv4Address(host) != nil || IPv6Address(host) != nil: diff --git a/ios/CodexPhone/CodexPhone/GatewayCredentials.swift b/ios/CodexPhone/CodexPhone/GatewayCredentials.swift index de26f5ff..86e7f27e 100644 --- a/ios/CodexPhone/CodexPhone/GatewayCredentials.swift +++ b/ios/CodexPhone/CodexPhone/GatewayCredentials.swift @@ -9,6 +9,7 @@ enum GatewayEndpoint { var components = URLComponents(string: value), components.user == nil, components.password == nil, + components.path.isEmpty || components.path == "/", components.query == nil, components.fragment == nil, let scheme = components.scheme?.lowercased(), diff --git a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift index a84e1396..83bdf862 100644 --- a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift +++ b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift @@ -51,13 +51,21 @@ final class RemoteDesktopTests: XCTestCase { XCTAssertFalse(canStartRemoteDesktop(statusText: "Host reachable, relay available")) } - func testGatewayRootURLRequiresHTTPOrHTTPSWithHost() throws { + func testGatewayRootURLRequiresHTTPSOrLoopbackAndServerOrigin() throws { let url = try gatewayRootURL(from: " https://codepilot.example.com ") XCTAssertEqual(url.absoluteString, "https://codepilot.example.com") + XCTAssertEqual( + try gatewayRootURL(from: "http://127.0.0.1:18790").absoluteString, + "http://127.0.0.1:18790" + ) assertInvalidGatewayRootURL("codepilot.example.com") assertInvalidGatewayRootURL("file:///tmp/codepilot") assertInvalidGatewayRootURL("http:///missing-host") + assertInvalidGatewayRootURL("http://192.0.2.10:18790") + assertInvalidGatewayRootURL("https://codepilot.example.com/api") + assertInvalidGatewayRootURL("https://" + "user:" + "password" + "@codepilot.example.com") + assertInvalidGatewayRootURL("https://codepilot.example.com?token=unsafe") } func testGatewaySetupValidationExplainsMissingFields() { @@ -84,7 +92,11 @@ final class RemoteDesktopTests: XCTestCase { gatewaySetupValidationMessage(url: "http://127.0.0.1:18790", token: "token", connectionKind: .local), "Same Network needs the Mac's LAN address, not localhost or 127.0.0.1." ) - XCTAssertNil(gatewaySetupValidationMessage(url: "http://192.0.2.10:18790", token: "token", connectionKind: .local)) + XCTAssertEqual( + gatewaySetupValidationMessage(url: "http://192.0.2.10:18790", token: "token", connectionKind: .local), + "Same Network connections must use https:// so the iOS connection token is encrypted." + ) + XCTAssertNil(gatewaySetupValidationMessage(url: "https://192.0.2.10:18790", token: "token", connectionKind: .local)) } func testGatewaySetupValidationRequiresHTTPSForCloudflare() { @@ -218,7 +230,7 @@ final class RemoteDesktopTests: XCTestCase { func testLocalWebSessionURLResolvesGatewayRelativePath() throws { let url = try XCTUnwrap(localWebSessionURL( path: "/api/local-web/session-1/dashboard?tab=logs", - baseURL: "https://gateway.example/base" + baseURL: "https://gateway.example" )) XCTAssertEqual(url.absoluteString, "https://gateway.example/api/local-web/session-1/dashboard?tab=logs") diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index 2e8a37a7..38eff6e5 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -13,5 +13,7 @@ Before public launch, maintainer coordination is still required for: - Enabling private vulnerability reporting before inviting public security reports. - Enabling dependency alerts and security updates, plus non-provider secret patterns and validity checks, then reviewing the initial Dependabot, CodeQL, and secret-scanning results. - Shipping pending credential-storage changes through an authorized build channel and rotating affected beta credentials. +- Shipping the pending HTTPS-only gateway-client hardening through the authorized OTA channel; this unattended scan may build locally but may not publish an OTA update. +- Approving one consistent upload-retention statement for the privacy policy and FAQ; the gateway now removes expired upload batches after seven days, while public privacy copy still says uploads remain until manual deletion. Keep fail-closed controls enabled until the corresponding private review is complete. Do not copy private findings into public issues or pull requests. From 800c6b848ded07c6548cd8ad8501af64a6a86b61 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Mon, 20 Jul 2026 10:47:32 +0200 Subject: [PATCH 05/13] security: sanitize gateway response headers --- gateway/codex_phone_gateway.py | 19 ++++++++++++++--- gateway/test_codex_phone_gateway.py | 28 +++++++++++++++++++++++++ ops/agents/escalations/security-scan.md | 5 ++--- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index fb25daa3..44cbdcb4 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -1008,11 +1008,23 @@ def file_metadata(path: Path) -> dict: } +def safe_http_header_value(value: object, fallback: str, *, max_length: int = 512) -> str: + candidate = str(value or "").strip() + if ( + not candidate + or len(candidate) > max_length + or any(ord(character) < 32 or ord(character) == 127 for character in candidate) + ): + return fallback + return candidate + + def file_response(handler: BaseHTTPRequestHandler, path: Path): metadata = file_metadata(path) - download_name = path.name.replace('"', "_") + content_type = safe_http_header_value(metadata["mimeType"], "application/octet-stream") + download_name = safe_filename(path.name, "download") handler.send_response(200) - handler.send_header("Content-Type", metadata["mimeType"]) + handler.send_header("Content-Type", content_type) handler.send_header("Cache-Control", "no-store") handler.send_header("Referrer-Policy", "no-referrer") handler.send_header("X-Content-Type-Options", "nosniff") @@ -1028,8 +1040,9 @@ def local_web_response(handler: BaseHTTPRequestHandler, payload: dict): if isinstance(body, str): body = body.encode("utf-8") status = int(payload.get("status") or 502) + content_type = safe_http_header_value(payload.get("contentType"), "application/octet-stream") handler.send_response(status) - handler.send_header("Content-Type", str(payload.get("contentType") or "application/octet-stream")) + handler.send_header("Content-Type", content_type) handler.send_header("Cache-Control", "no-store") handler.send_header("Referrer-Policy", "no-referrer") handler.send_header("X-Content-Type-Options", "nosniff") diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index 52c2802d..a622e67c 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -1461,6 +1461,34 @@ def test_file_metadata_reports_download_details(self): self.assertEqual(metadata["mimeType"], "text/plain") self.assertEqual(metadata["size"], 5) + def test_file_response_sanitizes_download_filename_header(self): + with tempfile.TemporaryDirectory() as tmp: + file_path = Path(tmp) / 'report\nX-Injected: yes.txt' + file_path.write_text("hello", encoding="utf-8") + handler = mock.Mock() + handler.wfile = io.BytesIO() + + gateway.file_response(handler, file_path) + + headers = dict(call.args for call in handler.send_header.call_args_list) + self.assertEqual(headers["Content-Disposition"], 'attachment; filename="report_X-Injected_ yes.txt"') + self.assertNotIn("\n", headers["Content-Disposition"]) + self.assertNotIn("\r", headers["Content-Disposition"]) + + def test_local_web_response_rejects_control_characters_in_content_type(self): + handler = mock.Mock() + handler.wfile = io.BytesIO() + + gateway.local_web_response(handler, { + "status": 200, + "contentType": "text/plain\r\nX-Injected: yes", + "body": b"hello", + }) + + headers = dict(call.args for call in handler.send_header.call_args_list) + self.assertEqual(headers["Content-Type"], "application/octet-stream") + self.assertNotIn("X-Injected", "\n".join(headers.values())) + def test_local_web_session_rejects_non_loopback_url(self): state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index 38eff6e5..e496540b 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -1,6 +1,6 @@ # CodePilot Security Maintenance Notice -Status reviewed: 2026-07-19. Public launch remains blocked on the maintainer-only items below; keep the documented fail-closed controls enabled. +Status reviewed: 2026-07-20. Public launch remains blocked on the maintainer-only items below; keep the documented fail-closed controls enabled. Detailed security findings are intentionally not stored in the public repository. Send vulnerability details, private identifiers, logs, and credential-related evidence through the maintainers' private security channel. @@ -10,8 +10,7 @@ Before public launch, maintainer coordination is still required for: - A credential and edge-access architecture review for the remotely reachable gateway. - Sanitizing historical private identifiers and commit metadata, followed by coordinated clone migration. - Protecting the default branch with required reviews and passing checks. -- Enabling private vulnerability reporting before inviting public security reports. -- Enabling dependency alerts and security updates, plus non-provider secret patterns and validity checks, then reviewing the initial Dependabot, CodeQL, and secret-scanning results. +- Enabling dependency alerts and security updates, plus non-provider secret patterns and validity checks, then reviewing and dispositioning the outstanding dependency, code-scanning, and secret-scanning results. - Shipping pending credential-storage changes through an authorized build channel and rotating affected beta credentials. - Shipping the pending HTTPS-only gateway-client hardening through the authorized OTA channel; this unattended scan may build locally but may not publish an OTA update. - Approving one consistent upload-retention statement for the privacy policy and FAQ; the gateway now removes expired upload batches after seven days, while public privacy copy still says uploads remain until manual deletion. From 6e9f044af3b550d4f4923e7dcc19ae6f33e709c7 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Mon, 20 Jul 2026 13:32:05 +0200 Subject: [PATCH 06/13] security: reject malformed authorization headers --- gateway/codex_phone_gateway.py | 10 +++++++++- gateway/test_codex_phone_gateway.py | 8 ++++++++ ops/agents/escalations/security-scan.md | 3 +-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index 44cbdcb4..2e655262 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -68,6 +68,7 @@ MAX_TOTAL_ATTACHMENT_BYTES = 50 * 1024 * 1024 MAX_JSON_BODY_BYTES = 1 * 1024 * 1024 MAX_ATTACHMENT_REQUEST_BYTES = 72 * 1024 * 1024 +MAX_AUTHORIZATION_HEADER_BYTES = 512 UPLOAD_RETENTION_SECONDS = 7 * 24 * 60 * 60 VALID_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} PUBLIC_JOB_TEXT_LIMIT = 4_000 @@ -4925,7 +4926,14 @@ def authenticate(self) -> bool: def is_authenticated(self) -> bool: expected = self.state().token header = self.headers.get("authorization", "") - return secrets.compare_digest(header, f"Bearer {expected}") + try: + header_bytes = header.encode("ascii") + expected_bytes = f"Bearer {expected}".encode("ascii") + except (AttributeError, UnicodeEncodeError): + return False + if len(header_bytes) > MAX_AUTHORIZATION_HEADER_BYTES: + return False + return secrets.compare_digest(header_bytes, expected_bytes) def do_GET(self): parsed = urllib.parse.urlparse(self.path) diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index a622e67c..ca375aac 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -1391,6 +1391,14 @@ def test_health_endpoint_returns_diagnostics_with_bearer_token(self): self.assertIn("accounts", payload) self.assertIn("notifications", payload) + def test_authentication_rejects_non_ascii_header_without_raising(self): + state = GatewayState(Path("/tmp/codex"), "secret-token", Path("/missing-codex"), False) + handler = object.__new__(gateway.Handler) + handler.server = type("Server", (), {"state": state})() + handler.headers = {"authorization": "Bearer secrét-token"} + + self.assertFalse(handler.is_authenticated()) + def test_error_payload_has_stable_code_message_and_recovery(self): payload = gateway.error_payload( "local_web_invalid_target", diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index e496540b..86904aa4 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -10,9 +10,8 @@ Before public launch, maintainer coordination is still required for: - A credential and edge-access architecture review for the remotely reachable gateway. - Sanitizing historical private identifiers and commit metadata, followed by coordinated clone migration. - Protecting the default branch with required reviews and passing checks. -- Enabling dependency alerts and security updates, plus non-provider secret patterns and validity checks, then reviewing and dispositioning the outstanding dependency, code-scanning, and secret-scanning results. +- Enabling dependency alerts and security updates, plus non-provider secret patterns and validity checks, then reviewing new results and dispositioning the outstanding code-scanning findings. - Shipping pending credential-storage changes through an authorized build channel and rotating affected beta credentials. - Shipping the pending HTTPS-only gateway-client hardening through the authorized OTA channel; this unattended scan may build locally but may not publish an OTA update. -- Approving one consistent upload-retention statement for the privacy policy and FAQ; the gateway now removes expired upload batches after seven days, while public privacy copy still says uploads remain until manual deletion. Keep fail-closed controls enabled until the corresponding private review is complete. Do not copy private findings into public issues or pull requests. From edab725f8dedf6fb2f5f5a1356796f9c20240305 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Mon, 20 Jul 2026 14:46:21 +0200 Subject: [PATCH 07/13] security: omit filenames from download headers --- gateway/codex_phone_gateway.py | 3 +-- gateway/test_codex_phone_gateway.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index 2e655262..8bd2bd03 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -1023,14 +1023,13 @@ def safe_http_header_value(value: object, fallback: str, *, max_length: int = 51 def file_response(handler: BaseHTTPRequestHandler, path: Path): metadata = file_metadata(path) content_type = safe_http_header_value(metadata["mimeType"], "application/octet-stream") - download_name = safe_filename(path.name, "download") handler.send_response(200) handler.send_header("Content-Type", content_type) handler.send_header("Cache-Control", "no-store") handler.send_header("Referrer-Policy", "no-referrer") handler.send_header("X-Content-Type-Options", "nosniff") handler.send_header("Content-Length", str(metadata["size"])) - handler.send_header("Content-Disposition", f'attachment; filename="{download_name}"') + handler.send_header("Content-Disposition", "attachment") handler.end_headers() with path.open("rb") as handle: shutil.copyfileobj(handle, handler.wfile) diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index ca375aac..bea16f49 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -1469,7 +1469,7 @@ def test_file_metadata_reports_download_details(self): self.assertEqual(metadata["mimeType"], "text/plain") self.assertEqual(metadata["size"], 5) - def test_file_response_sanitizes_download_filename_header(self): + def test_file_response_does_not_include_filename_in_header(self): with tempfile.TemporaryDirectory() as tmp: file_path = Path(tmp) / 'report\nX-Injected: yes.txt' file_path.write_text("hello", encoding="utf-8") @@ -1479,7 +1479,7 @@ def test_file_response_sanitizes_download_filename_header(self): gateway.file_response(handler, file_path) headers = dict(call.args for call in handler.send_header.call_args_list) - self.assertEqual(headers["Content-Disposition"], 'attachment; filename="report_X-Injected_ yes.txt"') + self.assertEqual(headers["Content-Disposition"], "attachment") self.assertNotIn("\n", headers["Content-Disposition"]) self.assertNotIn("\r", headers["Content-Disposition"]) From e366003b1ea41aa15b1b2dd8433dbdf26e23592c Mon Sep 17 00:00:00 2001 From: CodePilot Date: Mon, 20 Jul 2026 15:44:47 +0200 Subject: [PATCH 08/13] security: isolate localhost web storage --- docs/PRIVACY.md | 2 +- docs/SECURITY.md | 2 +- ios/CodexPhone/CodexPhone/CodexPhoneApp.swift | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 20902fba..0eb70c8f 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -72,7 +72,7 @@ Use an HTTPS tunnel URL. CodePilot still requires the gateway bearer token when When the iPhone app opens an `http` or `https` link to `localhost`, `127.0.0.1`, or `::1`, the authenticated Mac gateway creates a short-lived proxy session and requests that page from the Mac. Page bodies, URL paths, and query strings then pass through the gateway and the user-owned Cloudflare Tunnel to the iPhone. Local dashboards can contain source code, logs, filenames, prompts, or development data even when the link itself looks generic. -The local-web workflow accepts only loopback hosts; it is not a general website proxy. Session identifiers expire, response sizes are bounded, and the gateway bearer token is not placed in the proxied page URL. These limits reduce exposure but do not make page contents public-safe. Close sensitive local pages when finished and remove local-web URLs, contents, and screenshots from public reports. +The local-web workflow accepts only loopback hosts; it is not a general website proxy. Session identifiers expire, response sizes are bounded, the gateway bearer token is not placed in the proxied page URL, and the iPhone browser uses a nonpersistent WebKit data store. These limits reduce exposure but do not make page contents public-safe. Close sensitive local pages when finished and remove local-web URLs, contents, and screenshots from public reports. ## External Services diff --git a/docs/SECURITY.md b/docs/SECURITY.md index c9b1d55e..9946f95a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -50,7 +50,7 @@ CodePilot swaps local provider auth files. It waits for active turns to finish b ## Loopback Web Links -CodePilot can proxy `http` and `https` links for `localhost`, `127.0.0.1`, and `::1` through an authenticated, short-lived gateway session. Non-loopback targets are rejected. Treat local page contents, URL paths, and query strings as private because they pass through the gateway and Cloudflare Tunnel to the iPhone. +CodePilot can proxy `http` and `https` links for `localhost`, `127.0.0.1`, and `::1` through an authenticated, short-lived gateway session. Non-loopback targets are rejected, and the iPhone browser uses a nonpersistent WebKit data store. Treat local page contents, URL paths, and query strings as private because they pass through the gateway and Cloudflare Tunnel to the iPhone. Do not open local admin panels or dashboards containing credentials through this workflow. Do not share local-web session URLs, page contents, or screenshots in public issues. diff --git a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift index 26b03d86..1b4a7683 100644 --- a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift +++ b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift @@ -1765,6 +1765,7 @@ struct LocalWebView: UIViewRepresentable { func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() configuration.allowsInlineMediaPlayback = true let webView = WKWebView(frame: .zero, configuration: configuration) webView.allowsBackForwardNavigationGestures = true From d7bfc2921bd73a30f77f44640e69d047f0cb1cf3 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Mon, 20 Jul 2026 16:38:16 +0200 Subject: [PATCH 09/13] security: validate notification registration fields --- gateway/codex_phone_gateway.py | 60 +++++++++++++++++++++-------- gateway/test_codex_phone_gateway.py | 45 ++++++++++++++++++---- 2 files changed, 83 insertions(+), 22 deletions(-) diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index 8bd2bd03..9e91751b 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -69,6 +69,10 @@ MAX_JSON_BODY_BYTES = 1 * 1024 * 1024 MAX_ATTACHMENT_REQUEST_BYTES = 72 * 1024 * 1024 MAX_AUTHORIZATION_HEADER_BYTES = 512 +MIN_APNS_TOKEN_HEX_CHARS = 32 +MAX_APNS_TOKEN_HEX_CHARS = 200 +MAX_APNS_TOPIC_CHARS = 255 +MAX_LIVE_ACTIVITY_ID_CHARS = 128 UPLOAD_RETENTION_SECONDS = 7 * 24 * 60 * 60 VALID_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} PUBLIC_JOB_TEXT_LIMIT = 4_000 @@ -1244,11 +1248,44 @@ def is_connector_auth_warning(text: str) -> bool: def connector_name_from_text(text: str, fallback: str = "Connector") -> str: lower = str(text or "").lower() - if "mcp.cloudflare.com" in lower or "cloudflare" in lower: + if "cloudflare" in lower: return "cloudflare-api" return str(fallback or "Connector").strip() or "Connector" +def normalized_apns_token(raw_token: object) -> str: + token = str(raw_token or "").strip().lower() + if ( + len(token) < MIN_APNS_TOKEN_HEX_CHARS + or len(token) > MAX_APNS_TOKEN_HEX_CHARS + or len(token) % 2 != 0 + or re.fullmatch(r"[0-9a-f]+", token) is None + ): + raise ValueError("APNs token must be an even-length hexadecimal value") + return token + + +def validated_apns_topic(raw_topic: object) -> str: + topic = str(raw_topic or DEFAULT_APNS_TOPIC).strip() or DEFAULT_APNS_TOPIC + if ( + len(topic) > MAX_APNS_TOPIC_CHARS + or re.fullmatch(r"[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+", topic) is None + ): + raise ValueError("APNs bundle identifier is invalid") + return topic + + +def validated_live_activity_id(raw_activity_id: object) -> str: + activity_id = str(raw_activity_id or "").strip() + if ( + not activity_id + or len(activity_id) > MAX_LIVE_ACTIVITY_ID_CHARS + or re.fullmatch(r"[A-Za-z0-9._-]+", activity_id) is None + ): + raise ValueError("Live Activity identifier is invalid") + return activity_id + + def connector_display_name(name: str) -> str: raw = str(name or "").strip() if raw == "cloudflare-api": @@ -3290,19 +3327,16 @@ def account_profile_names(self) -> list[str]: return sorted(names, key=str.casefold) def register_notification_device(self, payload: dict) -> dict: - raw_token = str(payload.get("token") or "") - token = re.sub(r"[^0-9a-fA-F]", "", raw_token).lower() - if not token: - raise ValueError("Device token is required") + token = normalized_apns_token(payload.get("token")) environment = str(payload.get("environment") or "production").strip().lower() if environment not in {"development", "production"}: - environment = "production" - bundle_id = str(payload.get("bundleId") or DEFAULT_APNS_TOPIC).strip() or DEFAULT_APNS_TOPIC + raise ValueError("Notification environment must be development or production") + bundle_id = validated_apns_topic(payload.get("bundleId")) device = { "token": token, "environment": environment, "bundleId": bundle_id, - "platform": str(payload.get("platform") or "ios"), + "platform": "ios", "updatedAt": int(time.time()), } devices = [ @@ -3318,16 +3352,12 @@ def register_notification_device(self, payload: dict) -> dict: return {"ok": True, "deviceCount": len(devices[:20])} def register_live_activity(self, payload: dict) -> dict: - activity_id = str(payload.get("activityId") or "").strip() - if not activity_id: - raise ValueError("Live Activity identifier is required") - token = re.sub(r"[^0-9a-fA-F]", "", str(payload.get("pushToken") or "")).lower() - if not token: - raise ValueError("Live Activity push token is required") + activity_id = validated_live_activity_id(payload.get("activityId")) + token = normalized_apns_token(payload.get("pushToken")) environment = str(payload.get("environment") or "production").strip().lower() if environment not in {"development", "production"}: raise ValueError("Live Activity environment must be development or production") - bundle_id = str(payload.get("bundleId") or DEFAULT_APNS_TOPIC).strip() or DEFAULT_APNS_TOPIC + bundle_id = validated_apns_topic(payload.get("bundleId")) existing = next( (item for item in self.read_live_activities() if item.get("activityId") == activity_id), None, diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index bea16f49..b88805d3 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -2083,19 +2083,50 @@ def test_notification_device_registration_persists_token(self): state = GatewayState(tmp_path / "codex", "token", Path("/missing-codex"), False) response = state.register_notification_device({ - "token": " ABC123 ", + "token": " " + ("AB" * 32) + " ", "environment": "production", "bundleId": "io.codepilot.iOS", }) self.assertTrue(response["ok"]) devices = json.loads((switcher_home / "phone-notification-devices.json").read_text(encoding="utf-8")) - self.assertEqual(devices[0]["token"], "abc123") + self.assertEqual(devices[0]["token"], "ab" * 32) self.assertEqual(devices[0]["environment"], "production") self.assertEqual(devices[0]["bundleId"], "io.codepilot.iOS") finally: gateway.DEFAULT_SWITCHER_HOME = old_home + def test_notification_registration_rejects_malformed_or_unbounded_fields(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + old_home = gateway.DEFAULT_SWITCHER_HOME + gateway.DEFAULT_SWITCHER_HOME = tmp_path / "switcher" + try: + state = GatewayState(tmp_path / "codex", "token", Path("/missing-codex"), False) + valid_token = "ab" * 32 + + invalid_payloads = [ + {"token": "ab:not-a-token", "environment": "production"}, + {"token": valid_token, "environment": "staging"}, + {"token": valid_token, "environment": "production", "bundleId": "invalid\nheader"}, + {"token": valid_token, "environment": "production", "bundleId": "a" * 256}, + ] + for payload in invalid_payloads: + with self.subTest(payload=payload): + with self.assertRaises(ValueError): + state.register_notification_device(payload) + + with self.assertRaisesRegex(ValueError, "identifier"): + state.register_live_activity({ + "activityId": "../unsafe", + "pushToken": valid_token, + "environment": "production", + }) + self.assertFalse((gateway.DEFAULT_SWITCHER_HOME / "phone-notification-devices.json").exists()) + self.assertFalse((gateway.DEFAULT_SWITCHER_HOME / "phone-live-activities.json").exists()) + finally: + gateway.DEFAULT_SWITCHER_HOME = old_home + def test_live_activity_registration_is_separate_and_idempotent(self): with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) @@ -2106,18 +2137,18 @@ def test_live_activity_registration_is_separate_and_idempotent(self): state = GatewayState(tmp_path / "codex", "token", Path("/missing-codex"), False) payload = { "activityId": "activity-1", - "pushToken": " ABC123 ", + "pushToken": " " + ("AB" * 32) + " ", "environment": "production", "bundleId": "io.codepilot.iOS", } first = state.register_live_activity(payload) - second = state.register_live_activity({**payload, "pushToken": "def456"}) + second = state.register_live_activity({**payload, "pushToken": "de" * 32}) self.assertEqual(first["activityCount"], 1) self.assertEqual(second["activityCount"], 1) registrations = state.read_live_activities() - self.assertEqual(registrations[0]["pushToken"], "def456") + self.assertEqual(registrations[0]["pushToken"], "de" * 32) self.assertFalse((switcher_home / "phone-notification-devices.json").exists()) finally: gateway.DEFAULT_SWITCHER_HOME = old_home @@ -2132,7 +2163,7 @@ def test_live_activity_registration_rejects_invalid_environment(self): with self.assertRaisesRegex(ValueError, "environment"): state.register_live_activity({ "activityId": "activity-1", - "pushToken": "abc123", + "pushToken": "ab" * 32, "environment": "staging", }) finally: @@ -2154,7 +2185,7 @@ def test_unchanged_live_activity_state_is_not_pushed_twice(self): ) state.register_live_activity({ "activityId": "activity-1", - "pushToken": "abc123", + "pushToken": "ab" * 32, "environment": "production", "bundleId": "io.codepilot.iOS", }) From 123695686c8053fc7f63c3600062d2b0d63172a5 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Mon, 20 Jul 2026 17:32:45 +0200 Subject: [PATCH 10/13] security: canonicalize gateway response metadata --- gateway/codex_phone_gateway.py | 44 +++++++++++++++++++---------- gateway/test_codex_phone_gateway.py | 14 +++++++++ 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index 9e91751b..745589aa 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -73,6 +73,22 @@ MAX_APNS_TOKEN_HEX_CHARS = 200 MAX_APNS_TOPIC_CHARS = 255 MAX_LIVE_ACTIVITY_ID_CHARS = 128 +LOCAL_WEB_CONTENT_TYPES = { + "application/javascript": "application/javascript; charset=utf-8", + "application/json": "application/json; charset=utf-8", + "application/pdf": "application/pdf", + "application/wasm": "application/wasm", + "image/avif": "image/avif", + "image/gif": "image/gif", + "image/jpeg": "image/jpeg", + "image/png": "image/png", + "image/svg+xml": "image/svg+xml", + "image/webp": "image/webp", + "text/css": "text/css; charset=utf-8", + "text/html": "text/html; charset=utf-8", + "text/javascript": "text/javascript; charset=utf-8", + "text/plain": "text/plain; charset=utf-8", +} UPLOAD_RETENTION_SECONDS = 7 * 24 * 60 * 60 VALID_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} PUBLIC_JOB_TEXT_LIMIT = 4_000 @@ -1013,22 +1029,15 @@ def file_metadata(path: Path) -> dict: } -def safe_http_header_value(value: object, fallback: str, *, max_length: int = 512) -> str: - candidate = str(value or "").strip() - if ( - not candidate - or len(candidate) > max_length - or any(ord(character) < 32 or ord(character) == 127 for character in candidate) - ): - return fallback - return candidate +def canonical_local_web_content_type(value: object) -> str: + media_type = str(value or "").partition(";")[0].strip().lower() + return LOCAL_WEB_CONTENT_TYPES.get(media_type, "application/octet-stream") def file_response(handler: BaseHTTPRequestHandler, path: Path): metadata = file_metadata(path) - content_type = safe_http_header_value(metadata["mimeType"], "application/octet-stream") handler.send_response(200) - handler.send_header("Content-Type", content_type) + handler.send_header("Content-Type", "application/octet-stream") handler.send_header("Cache-Control", "no-store") handler.send_header("Referrer-Policy", "no-referrer") handler.send_header("X-Content-Type-Options", "nosniff") @@ -1044,7 +1053,7 @@ def local_web_response(handler: BaseHTTPRequestHandler, payload: dict): if isinstance(body, str): body = body.encode("utf-8") status = int(payload.get("status") or 502) - content_type = safe_http_header_value(payload.get("contentType"), "application/octet-stream") + content_type = canonical_local_web_content_type(payload.get("contentType")) handler.send_response(status) handler.send_header("Content-Type", content_type) handler.send_header("Cache-Control", "no-store") @@ -1267,9 +1276,14 @@ def normalized_apns_token(raw_token: object) -> str: def validated_apns_topic(raw_topic: object) -> str: topic = str(raw_topic or DEFAULT_APNS_TOPIC).strip() or DEFAULT_APNS_TOPIC - if ( - len(topic) > MAX_APNS_TOPIC_CHARS - or re.fullmatch(r"[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+", topic) is None + segments = topic.split(".") + if len(topic) > MAX_APNS_TOPIC_CHARS or len(segments) < 2 or any( + not segment + or any( + not (character.isascii() and (character.isalnum() or character == "-")) + for character in segment + ) + for segment in segments ): raise ValueError("APNs bundle identifier is invalid") return topic diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index b88805d3..69505478 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -1479,6 +1479,7 @@ def test_file_response_does_not_include_filename_in_header(self): gateway.file_response(handler, file_path) headers = dict(call.args for call in handler.send_header.call_args_list) + self.assertEqual(headers["Content-Type"], "application/octet-stream") self.assertEqual(headers["Content-Disposition"], "attachment") self.assertNotIn("\n", headers["Content-Disposition"]) self.assertNotIn("\r", headers["Content-Disposition"]) @@ -1497,6 +1498,19 @@ def test_local_web_response_rejects_control_characters_in_content_type(self): self.assertEqual(headers["Content-Type"], "application/octet-stream") self.assertNotIn("X-Injected", "\n".join(headers.values())) + def test_local_web_response_canonicalizes_allowed_content_type(self): + handler = mock.Mock() + handler.wfile = io.BytesIO() + + gateway.local_web_response(handler, { + "status": 200, + "contentType": "Text/HTML; charset=untrusted", + "body": b"

hello

", + }) + + headers = dict(call.args for call in handler.send_header.call_args_list) + self.assertEqual(headers["Content-Type"], "text/html; charset=utf-8") + def test_local_web_session_rejects_non_loopback_url(self): state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) From 34fda89c1e2fa8adb9573b2eb21202a6069f0a28 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Tue, 21 Jul 2026 19:34:13 +0200 Subject: [PATCH 11/13] security: report remote desktop fail-closed status --- gateway/remote_desktop_gateway.py | 7 +++++++ gateway/test_codex_phone_gateway.py | 4 ++++ gateway/test_remote_desktop_gateway.py | 10 ++++++++++ ops/agents/escalations/security-scan.md | 2 +- 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/gateway/remote_desktop_gateway.py b/gateway/remote_desktop_gateway.py index 99297c3e..51a93e91 100644 --- a/gateway/remote_desktop_gateway.py +++ b/gateway/remote_desktop_gateway.py @@ -200,6 +200,13 @@ def __init__( self._signals: dict[str, list[dict[str, Any]]] = {} self._last_signal_sequence: dict[str, int] = {} + def public_status(self) -> dict[str, Any]: + """Return policy state without probing the privileged native host.""" + return { + "available": self.remote_control_enabled, + "remoteControlAvailable": self.remote_control_enabled, + } + def handle( self, http_method: str, diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index 69505478..f55ff0e9 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -1295,6 +1295,10 @@ def test_diagnostic_health_retains_authenticated_setup_status(self): self.assertIn("auth", health["accounts"]) self.assertIn("notifications", health) self.assertIn("remoteDesktop", health) + self.assertEqual( + health["remoteDesktop"], + {"available": False, "remoteControlAvailable": False}, + ) self.assertIn("localWeb", health) def test_health_endpoint_is_available_without_bearer_token(self): diff --git a/gateway/test_remote_desktop_gateway.py b/gateway/test_remote_desktop_gateway.py index a45d3563..2bce3516 100644 --- a/gateway/test_remote_desktop_gateway.py +++ b/gateway/test_remote_desktop_gateway.py @@ -213,6 +213,16 @@ def call(self, method, payload=None): class RemoteDesktopGatewayTests(unittest.TestCase): + def test_public_status_reports_fail_closed_policy_without_probing_host(self): + host = FakeNativeHostClient() + gateway = RemoteDesktopGateway(host_client=host) + + self.assertEqual( + gateway.public_status(), + {"available": False, "remoteControlAvailable": False}, + ) + self.assertEqual(host.calls, []) + def test_remote_control_routes_fail_closed_by_default(self): host = FakeNativeHostClient() gateway = RemoteDesktopGateway(host_client=host) diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index 86904aa4..4ac5ad29 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -1,6 +1,6 @@ # CodePilot Security Maintenance Notice -Status reviewed: 2026-07-20. Public launch remains blocked on the maintainer-only items below; keep the documented fail-closed controls enabled. +Status reviewed: 2026-07-21. Public launch remains blocked on the maintainer-only items below; keep the documented fail-closed controls enabled. Detailed security findings are intentionally not stored in the public repository. Send vulnerability details, private identifiers, logs, and credential-related evidence through the maintainers' private security channel. From d7590bd09c994435311e29444d1beb889a1547bf Mon Sep 17 00:00:00 2001 From: CodePilot Date: Tue, 21 Jul 2026 20:25:16 +0200 Subject: [PATCH 12/13] security: restore private reporting escalation --- ops/agents/escalations/security-scan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index 4ac5ad29..7f431d30 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -10,6 +10,7 @@ Before public launch, maintainer coordination is still required for: - A credential and edge-access architecture review for the remotely reachable gateway. - Sanitizing historical private identifiers and commit metadata, followed by coordinated clone migration. - Protecting the default branch with required reviews and passing checks. +- Enabling private vulnerability reporting before inviting public security reports. - Enabling dependency alerts and security updates, plus non-provider secret patterns and validity checks, then reviewing new results and dispositioning the outstanding code-scanning findings. - Shipping pending credential-storage changes through an authorized build channel and rotating affected beta credentials. - Shipping the pending HTTPS-only gateway-client hardening through the authorized OTA channel; this unattended scan may build locally but may not publish an OTA update. From 3a0567f214a029059d0602ed9c92f711554134b3 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Wed, 22 Jul 2026 00:21:49 +0200 Subject: [PATCH 13/13] security: expand provider secret detection --- ops/agents/escalations/security-scan.md | 4 +++- scripts/privacy-audit.sh | 12 +++++++++++- scripts/tests/privacy-audit-test.sh | 13 ++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index 7f431d30..3a1e8055 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -1,6 +1,6 @@ # CodePilot Security Maintenance Notice -Status reviewed: 2026-07-21. Public launch remains blocked on the maintainer-only items below; keep the documented fail-closed controls enabled. +Status reviewed: 2026-07-22. Public launch remains blocked on the maintainer-only items below; keep the documented fail-closed controls enabled. Detailed security findings are intentionally not stored in the public repository. Send vulnerability details, private identifiers, logs, and credential-related evidence through the maintainers' private security channel. @@ -15,4 +15,6 @@ Before public launch, maintainer coordination is still required for: - Shipping pending credential-storage changes through an authorized build channel and rotating affected beta credentials. - Shipping the pending HTTPS-only gateway-client hardening through the authorized OTA channel; this unattended scan may build locally but may not publish an OTA update. +The 2026-07-22 re-scan found no conventional secret-shaped credentials in the current tree or repository history. The current tree passed the privacy audit, expanded provider-token regression suite, gateway tests, Swift tests, and iOS simulator tests. Historical private identifiers and non-public commit identities remain a launch blocker even though they are absent from the current tree. + Keep fail-closed controls enabled until the corresponding private review is complete. Do not copy private findings into public issues or pull requests. diff --git a/scripts/privacy-audit.sh b/scripts/privacy-audit.sh index 25958a71..6d2fa06e 100755 --- a/scripts/privacy-audit.sh +++ b/scripts/privacy-audit.sh @@ -65,6 +65,8 @@ secret_patterns=( "g""hp_[A-Za-z0-9_]{20,}" "g""ho_[A-Za-z0-9_]{20,}" "g""hu_[A-Za-z0-9_]{20,}" + "g""hs_[A-Za-z0-9_]{20,}" + "g""hr_[A-Za-z0-9_]{20,}" "github_""pat_[A-Za-z0-9_]{20,}" "(^|[^A-Za-z0-9])s""k-[A-Za-z0-9]{20,}" "(^|[^A-Za-z0-9])s""k-proj-[A-Za-z0-9]{20,}" @@ -77,11 +79,19 @@ secret_patterns=( 'Bearer [A-Za-z0-9._-]{20,}' 'AKIA[A-Z0-9]{16}' "AI""za[0-9A-Za-z_-]{35}" - "xox""b-[A-Za-z0-9-]{20,}" + "xox""[baprs]-[A-Za-z0-9-]{20,}" 'xapp-[A-Za-z0-9-]{20,}' "s""k_live_[A-Za-z0-9]{20,}" + "s""k_test_[A-Za-z0-9]{20,}" + "r""k_live_[A-Za-z0-9]{20,}" 'glpat-[A-Za-z0-9_-]{20,}' 'npm_[A-Za-z0-9]{20,}' + "pypi-""[A-Za-z0-9_-]{40,}" + "hf_""[A-Za-z0-9]{30,}" + "SG\.""[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}" + "dop_v1_""[A-Za-z0-9]{40,}" + "AGE-SECRET-KEY-""1[A-Z0-9]{20,}" + "-----BEGIN PGP PRIVATE ""KEY BLOCK-----" 'eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{20,}' "client_""secret[[:space:]]*[:=]" "private_""key[[:space:]]*[:=]" diff --git a/scripts/tests/privacy-audit-test.sh b/scripts/tests/privacy-audit-test.sh index d31213ed..e8b087f8 100755 --- a/scripts/tests/privacy-audit-test.sh +++ b/scripts/tests/privacy-audit-test.sh @@ -49,18 +49,29 @@ assert_rejected_without_echo "non-placeholder OTA host" "https://ota.internal.in assert_rejected_without_echo "secret-looking value" "Bearer AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" assert_rejected_without_echo "GitHub OAuth token" "g""ho_0123456789abcdefghijklmnopqrstuvwxyz" assert_rejected_without_echo "GitHub user token" "g""hu_0123456789abcdefghijklmnopqrstuvwxyz" +assert_rejected_without_echo "GitHub app token" "g""hs_0123456789abcdefghijklmnopqrstuvwxyz" +assert_rejected_without_echo "GitHub refresh token" "g""hr_0123456789abcdefghijklmnopqrstuvwxyz" assert_rejected_without_echo "OpenAI project key" "s""k-proj-0123456789abcdefghijklmnopqrstuvwxyz" assert_rejected_without_echo "Supabase access token" "sbp_0123456789abcdefghijklmnopqrstuvwxyz" assert_rejected_without_echo "Supabase secret key" "sb_secret_0123456789abcdefghijklmnopqrstuvwxyz" assert_rejected_without_echo "RSA private key" "-----BEGIN RSA PRIVATE KEY-----" assert_rejected_without_echo "EC private key" "-----BEGIN EC PRIVATE KEY-----" -assert_rejected_without_echo "cloud access key" "AKIAABCDEFGHIJKLMNOP" +assert_rejected_without_echo "cloud access key" "AKIA""ABCDEFGHIJKLMNOP" assert_rejected_without_echo "Google API key" "AI""za01234567890123456789012345678901234" assert_rejected_without_echo "Slack token" "xox""b-1234567890-abcdefghijklmnop" +assert_rejected_without_echo "Slack user token" "xox""p-1234567890-abcdefghijklmnop" assert_rejected_without_echo "Slack app token" "xapp-1-0123456789-abcdefghijklmnop" assert_rejected_without_echo "live payment key" "s""k_live_0123456789abcdefghijklmnop" +assert_rejected_without_echo "test payment key" "s""k_test_0123456789abcdefghijklmnop" +assert_rejected_without_echo "restricted payment key" "r""k_live_0123456789abcdefghijklmnop" assert_rejected_without_echo "GitLab token" "glpat-0123456789abcdefghijklmnop" assert_rejected_without_echo "npm token" "npm_0123456789abcdefghijklmnopqrstuv" +assert_rejected_without_echo "PyPI token" "pypi-""0123456789abcdefghijklmnopqrstuvwxyzABCDEFGH" +assert_rejected_without_echo "Hugging Face token" "hf_""0123456789abcdefghijklmnopqrstuvwxyz" +assert_rejected_without_echo "SendGrid token" "SG.""0123456789abcdefghijklmnop.ABCDEFGHIJKLMNOPQRSTUVWX" +assert_rejected_without_echo "DigitalOcean token" "dop_v1_""0123456789abcdefghijklmnopqrstuvwxyzABCD" +assert_rejected_without_echo "age private key" "AGE-SECRET-KEY-1ABC""DEFGHIJKLMNOPQRSTUVWXYZ234567890" +assert_rejected_without_echo "PGP private key" "-----BEGIN PGP PRIVATE ""KEY BLOCK-----" assert_rejected_without_echo "JWT" "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJleGFtcGxlIn0.abcdefghijklmnopqrstuvwxyz012345" write_fixture "Safe tracked content"