The uncomfortable truth about proxy security
A local HTTPS proxy intentionally performs a controlled man-in-the-middle operation. That is the point: the developer asks the proxy to terminate client TLS, inspect the HTTP layer, and open a separate TLS connection to the upstream server.
Security comes from making that operation explicit and bounded. The user must choose to install and trust the local root certificate. Decryption should be scoped to the hosts and flows the user wants to inspect. Pinned clients should not be misrepresented. Exports should be reviewed. Sensitive fields should be redacted before evidence leaves the machine.
This is the difference between a developer tool and a risky black box. Rockxy is built as a local-first macOS app with an open-source core so engineers can inspect how the trust boundary works.
Evidence from the source tree
The security model in this post maps to concrete files in the open-source Rockxy repo:
- CertificateStore.swift keeps the private key Keychain-first with a restricted disk fallback.
- CertificateManager.swift owns root CA trust install, fingerprint tracking, and host certificate cache state.
- ConnectionValidator.swift validates helper callers before privileged operations run.
- BreakpointRequestBuilder.swift reconciles edited request headers and body length.
- SessionStore.swift stores larger bodies as files with restricted permissions.
Certificate trust is a lifecycle, not a checkbox
Rockxy's app-side certificate management owns root CA creation, loading, trust-state verification, per-host certificate issuance, caching, and reset behavior. The public app docs describe a P-256 ECDSA root CA generated on first launch, stored through Keychain-backed workflows, and used to create per-host leaf certificates for HTTPS interception.
The practical lifecycle matters: generate a root, install it only when the user asks, verify trust state before decrypting, issue leaf certificates only from the active root, track the root fingerprint, and provide rotation or reset paths when trust state drifts.
When a client enforces certificate pinning, the correct behavior is not to fake success. Pinned clients may reject the local interception certificate. A debugging app should pass through, bypass, or clearly explain the limit so the developer understands why decrypted bodies are unavailable.
The private key path is intentionally Keychain-first. A disk PEM exists only as a recovery fallback if Keychain storage fails, and that fallback is written with restricted permissions.
enum CertificateStore {
static func saveRootCAPrivateKey(_ key: P256.Signing.PrivateKey) throws {
let keyData = key.pemRepresentation.data(using: .utf8)!
do {
try KeychainHelper.savePrivateKey(keyData, label: keychainKeyLabel)
logger.info("Saved root CA private key to Keychain")
return
} catch {
logger.warning("Keychain save failed; falling back to disk PEM")
}
try keyData.write(to: privateKeyPath, options: .atomic)
try FileManager.default.setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: privateKeyPath.path
)
}
}
Source: CertificateStore.swift.
Trust installation also tracks a fingerprint and validates system trust after install. That makes stale-root cleanup and post-install verification part of the lifecycle, not a separate manual checklist.
actor CertificateManager {
func installAndTrust() async throws {
if rootCACertificate == nil {
try generateRootCA()
}
let derData = try serialize(rootCACertificate!)
let fingerprint = computeFingerprint(rootCACertificate!)
?? KeychainHelper.computeFingerprintSHA256(derData)
if Self.shouldUseHelperForTrustInstall(status: helperStatus, isReachable: true) {
try await helperConnection.cleanupStaleCertificates(activeFingerprint: fingerprint)
try await helperConnection.installRootCertificate(derData: derData)
KeychainHelper.removeAllRockxyCertsFromLoginKeychain(label: Self.keychainCertLabel)
} else {
try KeychainHelper.installRootCAWithTrust(derData, label: Self.keychainCertLabel)
}
activeRootFingerprint = fingerprint
lastTrustValidationResult = validateSystemTrust()
}
}
Source: CertificateManager.swift.
Privileged work belongs behind a narrow boundary
On macOS, system proxy settings and certificate installation can require admin rights. Rockxy separates that work into a helper process instead of letting every UI path perform privileged operations directly.
The security architecture documented in the app repo describes a launchd helper, XPC communication, code-signing requirements, helper-side connection validation, and certificate-chain comparison. The important design idea is simple: the UI process requests privileged operations through a narrow interface, and the helper validates who is calling before it changes system state.
That boundary reduces the blast radius of UI bugs. It also makes security review easier. Reviewers can ask focused questions: Which operations are privileged? Which parameters cross the boundary? How are callers validated? What input is accepted? What happens on failure?
enum ConnectionValidator {
static func isValidCaller(_ connection: NSXPCConnection) -> Bool {
let auditTokenData = extractAuditTokenData(from: connection)
return validateCaller(
pid: connection.processIdentifier,
auditTokenData: auditTokenData
)
}
static func validateCaller(pid: pid_t, auditTokenData: Data?) -> Bool {
let pidValid = CallerValidation.validateCaller(
pid: pid,
allowedIdentifiers: allowedCallerIdentifiers
)
guard pidValid else { return false }
if let tokenData = auditTokenData,
let auditCode = CallerValidation.secCodeFromAuditToken(tokenData)
{
return CallerValidation.callerSatisfiesAnyIdentifier(
callerCode: auditCode,
allowedIdentifiers: allowedCallerIdentifiers
)
}
return true
}
}
Source: ConnectionValidator.swift. Tests: ConnectionValidatorTests.swift.
Input limits protect the proxy under load
Network tools parse untrusted input all day. A local debugger may be pointed at development services, staging systems, third-party APIs, device traffic, or broken applications. Defensive limits are not decoration; they keep the tool usable when traffic is malformed or hostile.
The Rockxy security docs call out concrete limits and controls: request body caps, URI length caps, WebSocket frame and connection limits, regex validation with pattern and input caps, path containment for Map Local file serving, header value sanitization, helper parameter validation, and centralized request rebuilding for breakpoint edits.
Those controls map to real failure modes. Oversized bodies can exhaust memory. Path traversal can escape a configured local directory. Pathological regex can burn CPU. Header injection can alter upstream semantics. Breakpoint edits can produce malformed requests if content length, scheme, authority, and headers are not reconciled together.
Breakpoint edits are a good example of security-by-structure. The builder pins HTTPS host authority and reconciles Content-Length / Transfer-Encoding against the edited body before the request returns to the proxy pipeline.
enum BreakpointRequestBuilder {
static func build(
from modifiedData: BreakpointRequestData,
originalHead: HTTPRequestHead,
originalRequestData: HTTPRequestData,
isHTTPS: Bool = false,
originalHost: String? = nil
) -> Result {
var resolvedHeaders = modifiedData.headers.map {
HTTPHeader(name: $0.name, value: $0.value)
}
if isHTTPS, let host = originalHost {
resolvedHeaders.removeAll { $0.name.caseInsensitiveCompare("Host") == .orderedSame }
resolvedHeaders.append(HTTPHeader(name: "Host", value: host))
}
let body = modifiedData.body.isEmpty ? nil : modifiedData.body.data(using: .utf8)
resolvedHeaders.removeAll { $0.name.caseInsensitiveCompare("Transfer-Encoding") == .orderedSame }
resolvedHeaders.removeAll { $0.name.caseInsensitiveCompare("Content-Length") == .orderedSame }
if let body, !body.isEmpty {
resolvedHeaders.append(HTTPHeader(name: "Content-Length", value: "\(body.count)"))
}
}
}
Source: BreakpointRequestBuilder.swift. Tests: BreakpointRequestBuilderTests.swift.
Redaction is part of the product, not only export cleanup
Captured traffic can contain Authorization headers, cookies, bearer tokens, API keys, user records, tenant IDs, internal hostnames, and request bodies that were never meant for a public issue. A useful debugging tool should help preserve evidence while reducing exposure.
Rockxy's sessions and export workflow support HAR import/export, cURL and JSON export, and redaction-aware sharing. The design goal is not "never export." Engineers need to share bugs. The goal is to make export a deliberate step where the user can remove sensitive values before another person or tool sees the data.
This is also why local MCP integration needs a redaction boundary. AI-assisted debugging is useful when the assistant can reason over the relevant request, response, replay input, and diff. It is dangerous when a tool blindly forwards all captured traffic. The assistant should receive selected, redacted evidence.
Storage should assume traffic is sensitive
Local-first does not mean risk-free. Local files can be copied, synced, backed up, indexed, or committed by accident. Rockxy treats saved sessions, exports, logs, and large body offload files as sensitive project artifacts.
The app security notes describe SQLite plus disk persistence, explicit save/export flows, large-body offload with restricted file permissions, path containment on load and delete, and credential redaction in logs. Those details matter because a proxy handles data that developers often cannot reproduce later. If it is stored, it deserves careful handling.
The session store keeps large bodies out of SQLite rows and writes them as separate files. The file path then becomes explicit session metadata, and the body file gets restricted permissions.
actor SessionStore {
private static let bodySizeThreshold = 1_048_576
private func processBody(_ body: Data?, existingPath: String?) -> (Data?, String?) {
guard let body else { return (nil, nil) }
guard body.count > Self.bodySizeThreshold else {
return (body, existingPath)
}
let fileURL = bodiesDirectory.appendingPathComponent(UUID().uuidString)
try? body.write(to: fileURL, options: .atomic)
try? FileManager.default.setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: fileURL.path
)
return (nil, fileURL.path)
}
}
Source: SessionStore.swift.
Security posture for the AI and blockchain era
AI and Web3/RPC debugging add more sensitive payloads, not fewer. Model prompts can include customer text. Tool-call payloads can include private file names or internal URLs. RPC flows can include addresses and transaction context. Payment-gated HTTP flows can include headers that should not leave a local debugging environment.
Rockxy's future direction is to make those flows easier to understand while preserving the same trust model: capture locally, inspect deeply, redact before sharing, and keep unfinished protocol-specific work clearly marked as direction until it ships.
Related technical series
For the broader strategy, read Local-First Network Evidence. For the Swift, SwiftUI, AppKit, and SwiftNIO implementation story, read Inside a Native macOS Network Debugger.
Frequently asked questions
Is HTTPS interception safe?
It is a sensitive debugging technique. It should be explicit, local, scoped, auditable, and reversible. Rockxy uses local certificate trust and per-host decryption rules so engineers can inspect traffic they intentionally route through the proxy.
What happens with certificate-pinned apps?
Pinned clients may reject a locally generated interception certificate. Rockxy should pass through or bypass those flows instead of pretending decryption is possible.
Why is redaction part of security?
Captured traffic often contains credentials, cookies, tokens, user data, internal hosts, and tenant identifiers. Redaction reduces the data that leaves the Mac when a developer exports or shares debugging evidence.
Why does Rockxy use a helper process?
System proxy and certificate operations can require admin rights on macOS. A helper keeps privileged work behind a narrow interface with caller validation.