A browser is an outbound capability: how I separate destination, data and authority
Six mixed DNS-answer fixtures. Six refusals. Replace the all-address check with a first-address-only check, and one of those mixed fixtures gets through.
That is a small offline result with a larger architectural consequence: the browser security boundary lives in the relationship between the decision and the eventual connection. A reasonable-looking URL is only the start of that relationship.
I built the browser proxy around a specific rule: resolve the destination, reject an unacceptable answer set, and pass the selected address into the connection. Then I treated a second question separately: even if we can reach this destination, what information are we allowed to send there?
That second question is where agent architecture gets interesting. Browsing, searching, previewing and rendering are routinely presented as ways to acquire information. Each can also transmit information. A search query leaves the system. A page request carries a path and query. An image fetch contacts a remote host. A preview service may fetch a link before anyone deliberately opens it.
The useful operator question is therefore concrete: which component owns the final outbound value, and can that component refuse it before transmission?
The read operation contains a send
Consider an assistant working with an internal document and a public website. The user asks it to research a topic. The model creates a search query using context from both sources. The search service is an intended destination. That says nothing about whether every string in the query is appropriate to disclose to that service.
An origin allowlist can correctly admit the request while an information-flow policy should reject the content. Those decisions need different inputs. The network decision needs a parsed origin, acceptable addresses and a connection path. The data decision needs the actual outbound value, its source and sensitivity, the intended recipient and the user's authority.
In my offline exercise, the origin predicate accepts both an ordinary path and a query containing the explicit dummy value FAKE_DATA at an allowed origin. That is the right result for an origin predicate. It also makes the limitation visible without needing a real secret or a live service.
I want the implementation responsibilities clear. Our browser proxy supplies concrete destination controls. Our broader firewall design identifies browsing, search and rendered resources as outbound sinks. Bringing the two together is an integration contract: the component allowed to send must receive the relevant content and authority decision. A proxy that sees an encrypted tunnel cannot make that semantic decision from the hidden request body.
Start with origins, then resolve addresses
I use parsed HTTP or HTTPS origins rather than suffix matching. A hostname that merely contains an approved domain is a different destination. A different port is a different origin. Embedded user information is rejected rather than carried into downstream interpretation.
Here is the compact shape of the origin check, adapted from our implementation:
function browserUrlAllowed(raw: string, origins: readonly string[]): boolean {
try {
const u = new URL(raw);
return (u.protocol === 'http:' || u.protocol === 'https:')
&& !u.username && !u.password
&& origins.includes(u.origin);
} catch {
return false;
}
}
That predicate intentionally ignores the meaning of the query. It does its own job and can be paired with a content decision without pretending to be one.
The shared schema constrains a browser grant to one through sixteen origins. It permits a lifetime from thirty to 3,600 seconds, with a default of 900. These are implementation bounds. They establish a bounded capability shape; they are not evidence of how long an actual session ran or how quickly revocation reached a worker.
The next decision concerns addresses. An approved name can resolve to an unacceptable address. A returned set can also contain a mixture. Our function asks the resolver for all answers, rejects an empty set, and rejects the set when any answer is disallowed, unless a narrowly configured local exception applies.
The central condition is short:
const addresses = isIP(name)
? [{ address: name }]
: await resolve(name, { all: true });
if (!addresses.length || addresses.some(a =>
isDisallowedIpAddress(a.address) && !local
)) throw new Error('browser_destination_refused');
return addresses[0]!.address;
The complete address classifier is a separate dependency. Keeping that separation matters during review: exercising the caller with a declared classifier does not establish that every private, mapped, reserved or special address form is correctly classified in the product.
Carry the checked address into the socket
The less obvious part comes after validation. If I validate a hostname and then ask another component to connect using that hostname, the second component may resolve it again. I have checked one answer and used another.
Our plain HTTP path passes the selected address into the request's hostname field. It preserves the original host in the Host header. Its connection is therefore addressed using the decision we already made, while the HTTP request still names the intended origin.
const address = await browserAddress(u.hostname, port, localOrigins);
const headers = { ...endToEndHeaders(req.headers), host: u.host };
const upstream = httpRequest({
hostname: address,
port,
method: req.method,
path: u.pathname + u.search,
headers,
agent: false,
}, onAnswer);
For a checked CONNECT tunnel, the socket is created with that address as its host. This is the code-level binding I care about. An integration test should additionally observe the real remote socket; a code excerpt and a toy connector are different evidence.
The offline connector model makes the gap explicit: its first synthetic resolution returns a documentation address; its second returns a blocked fixture. Dialing the hostname in that model uses the second answer. Carrying the previously selected address uses the first. No socket opens in that exercise.
Pinning is local to a connection decision. It does not freeze DNS globally, solve redirects by itself or establish that every network library in the application uses this route. Connection reuse and pooling also need a policy owner. An approved connection should not become a way to borrow another session's destinations or lifetime.
Make local development exceptions precise
A browser agent often needs a local development server. Removing all local access can make the tool impractical. Allowing arbitrary loopback access creates a much larger capability than the task requires.
Our local exception requires a loopback name and an exact configured hostname and port. The offline exercise permits the explicitly configured loopback port 43219 and refuses ports 80, 443 and 43220 under that configuration. Those are fixture ports, not a report about exposed production services.
The deeper design rule is to make the exception an ordinary scoped capability. It needs an owner, an exact destination and an expiry. “Local development” should never become a permanent global exemption inherited by unrelated agents.
A matching port alone is also insufficient. Preserve the destination identity and the protocol policy around the exception. If a product uses a different network namespace or forwards local ports across machines, the meaning of local must be established at the actual connection point.
A checked tunnel is still opaque
The proxy admits HTTPS through CONNECT after validating the target origin and address. It then relays encrypted traffic. The browser remains responsible for the end-to-end TLS session.
That means the proxy can make a destination decision without being able to inspect the encrypted URL path, query or body. I would not place a semantic disclosure policy in a component that does not have the material it is supposed to judge.
There are several credible architectures. A typed search or fetch adapter can inspect the structured request before encrypting it. A browser integration can expose proposed navigation and resource requests to a trusted policy layer. A constrained browsing service can own both the semantic boundary and the network transport. Each requires explicit coverage and an argument about bypass paths.
TLS interception changes the trust and operational model substantially. I would only choose it for a requirement that justifies certificate management, privacy implications and compatibility work. It should not enter the architecture accidentally because a diagram put a “content check” inside a tunnel box.
Our implementation deliberately destroys plain WebSocket upgrade attempts. Secure WebSocket traffic uses the checked CONNECT path. That is a specific support contract. “WebSockets covered” is too vague to describe it accurately.
Headers belong to the connection boundary
A proxy also has to distinguish connection-scoped metadata from end-to-end metadata. RFC 9110 section 7.6.1 defines the Connection field's role in nominating fields for the current connection. Forwarding those nominations blindly can carry the wrong semantics onto the next hop.
Our helper removes a fixed set of hop fields and every field named by Connection, with case normalization of those nominations. It runs on both request and response forwarding. The offline exercise varies the nominated field's spelling three ways, confirms its removal and confirms that an ordinary end-to-end fixture header remains.
The important claim is narrow: the helper handles those cases. It does not prove every HTTP parser combination or request-smuggling behavior is safe. Those need independent protocol-level testing at the actual deployment boundaries.
The standard provides the protocol rationale here: RFC 9110, connection-specific fields. The address and redirect review also aligns with the OWASP SSRF prevention guidance. Neither reference substitutes for testing our route.
Follow every fetch path
A redirect is another destination decision. An image request is another outbound request. A preview performed by a backend service may never touch the browser proxy at all.
I map these as separate edges in the system rather than declaring the entire product covered because one egress component is good. For a browser, I want navigations, subresources, downloads and tunnel establishment accounted for. For a backend preview service, I want its own HTTP client, redirect behavior and address policy identified. For model-generated output, I want to know whether the client automatically fetches remote images or unfurls links.
A trusted interface can create an outbound effect while rendering untrusted text. That makes the renderer an actuator. The fact that the assistant only returned text does not settle what the client subsequently does with it.
The architecture I recommend gives each fetch path a deliberate policy owner. Disable unwanted automatic fetching where possible. Where fetching is required, constrain destination and data, bind the action to the right audience and authority, and record the result without storing sensitive request material unnecessarily.
I also separate external content from operating instructions. A page can contribute evidence to an answer. It cannot expand the agent's destinations, add a credential or approve a new outbound payload simply by asking convincingly inside retrieved content.
Bound the capability independently of classification
The proxy tracks sockets and applies a thirty-second idle timeout. The HTTP upstream has a fifteen-second timeout. A cumulative sixty-four-mebibyte counter destroys the tracked sockets when its threshold is exceeded.
Those figures describe configured mechanisms. They are not measured request latency, a full-session deadline or proof of a precise application-payload allowance. An idle socket timeout and a total session expiry solve different problems. A transport byte counter's accounting unit depends on the events it counts; I would not relabel it as billable provider bytes.
Our grant schema's lifetime bounds are another control. A short-lived capability reduces the period in which an abandoned session can act, provided the consuming route actually enforces its expiry. Current authority still matters if access is revoked before that time.
Similarly, a byte limit cannot decide whether the first kilobyte is sensitive. A semantic policy cannot stop an otherwise permitted connection from consuming unlimited resources unless the transport also enforces limits. These controls compose because they govern different variables.
For credentials, my preferred adapter accepts a reference to a scoped credential and owns the fixed destination and permitted operation. It materializes the secret at the final trusted boundary. Giving a model a secret string and asking it to remember which sites may receive it creates a much harder information-flow problem.
What the exercise establishes
The retained exercise executes three actual source-bound pure functions: URL admission, address selection and hop-header filtering. It injects DNS answers and a small synthetic address classifier. Across thirty-one predicate cases, it covers six mixed answer sets, two all-allowed ordering controls, one empty answer, one exact local-port acceptance, three neighboring-port refusals, five blocked literal fixtures, three header cases, eight URL cases, one configured non-loopback refusal and one bracketed IPv6 local acceptance.
The deliberate mutation replaces the all-address refusal with a first-address-only refusal. A mixed fixture beginning with an allowed address then passes. That control is useful because it demonstrates the consequence of weakening this particular rule, rather than merely restating that the source contains an array method.
The accepted FAKE_DATA query illustrates the boundary between destination and content policy. The connector model illustrates a validation-to-use gap. Neither is a production exploit result. Together they provide a small reproducible teaching artifact and a better specification for an integration test.
For that next test, I would use a dedicated disposable service, an observer that records actual connection destinations, controlled redirects, a synthetic canary and an explicit network policy. The observer should confirm where the request went and whether the canary arrived. It should also demonstrate that an authorized ordinary request still succeeds. Success means both the refusal and the useful behavior hold under the intended route.
Give the semantic boundary an explicit request contract
For the broader design, I would pass a structured outbound intent rather than a free-form sentence saying the model intends to browse safely. The following is a proposed interface, not an implemented proxy feature:
type OutboundIntent = {
actorRef: string;
grantRef: string;
audienceRef: string;
destinationOrigin: string;
method: string;
payloadDigest: string;
sourceRefs: readonly string[];
expiresAt: string;
policyRevision: string;
};
The trusted sender constructs the final request, recomputes its payload digest and checks that the decision applies to those exact bytes and destination. It resolves credential references internally. A redirect, retry with a changed body or transformed query may require another decision because the intended effect has changed.
The digest binds a decision to content; it does not explain whether the content is appropriate. The source references and audience help the semantic policy make that judgment. The grant reference and expiry let the sender establish whether the actor can still act. The policy revision gives an observer something precise to reconcile when the active rules change.
I would record decision identifiers and bounded outcome metadata rather than duplicate full sensitive requests in every audit log. The observer must still have enough information to connect a decision with a real send or refusal. That is a deliberate privacy and diagnosability tradeoff, not an invitation to log every query forever.
The acceptance contract I would hand to an owner
I want an inventory row for each sending component: browser navigation, subresource loader, search adapter, link-preview backend and output renderer. Each row names the code owner, destination policy, content policy, authority binding, limits and observation point.
Then I trace one synthetic value from source to recipient. Can an external page cause the value to enter a query? Can a renderer create a request without the main tool broker? Does a redirect cross the policy boundary? Is the actual connection address the one that was checked? Does expiry stop a session that is otherwise still technically connected?
The tradeoff is real. Additional request mediation can reduce compatibility, add work to the critical path and force product decisions about automatic previews. I want those costs measured per route, with useful successful tasks as the denominator. This exercise has no integrated latency, model-spend or compatibility measurement to attach to them.
Start with one path you currently call a read. Write down every outbound byte it can produce, who receives it and which component can still say no. That is the smallest useful egress audit—and a better foundation for agent autonomy than trusting the tool's name.
How this was verified
The implementation excerpts come from our browser proxy and shared browser policy. The package retains their hashes and extracted source. On October 19, 2026, an offline Node exercise ran thirty-one predicate cases plus a deliberately weakened-check control and a toy resolution-gap model. It made zero network requests and used synthetic address classification; it did not run the product CIDR classifier, browser integration suite, socket or TLS tests. Proxy limits and grant bounds are code facts, not benchmark results. Broader semantic and renderer controls above are architecture recommendations unless explicitly identified as implemented. No model inference or new paid API call was used for the exercise.
