Compatibility

Does Obfuscation Break DNS and Socket Options?

Outbound connections fail in the least convenient way: not at the call site, but minutes later, in a pool, on one host, under load. The options that prevent that are small and easy to overlook. Which address family to ask for, whether to take one answer or all of them, how long to wait for a peer that has stopped talking, how many times to retry a resolver that is gone. Every one is a property name owned by node.

What was measured

One file against node's dns and net modules, driven through five protection profiles for the base column and both member-renaming profiles for the rest. It performs four operations a connection pool performs: resolve a host for a service reachable only over IPv4, resolve the same host asking for every address it has, connect to a peer that accepts the connection and then says nothing at all, and query a resolver pointed at an address where nothing answers.

Every value differs from node's default. The default address family is either, a lookup returns one address rather than all of them, sockets have no timeout, and node's resolver allows four tries over five seconds where this file allows one try over 400 milliseconds.

The base column is clean. All five profiles resolved the same address in the same family, received the same array shape, timed out on the wedged peer inside the same window and gave up on the dead resolver in the same window. Protecting a service that makes outbound connections does not change how it connects.

An IPv4-only service resolved over IPv6

The first arm renamed the address family and the resolution hints. Before, the lookup returned the IPv4 loopback address and reported family 4. After, it returned the IPv6 loopback and reported family 6.

Nothing failed. The lookup succeeded, and it returned a perfectly valid address for the hostname asked about. It is simply the wrong one for a service that only listens on IPv4, and the failure surfaces at connect time, from a pool, against an address nobody asked for.

This is the failure that gets diagnosed as a networking problem. The application requested IPv4 in code that is still there and still says four; the resolver was asked for no particular family and answered with its own preference. Whether that preference matches production depends on the host, which is why it reproduces in one environment and not another.

The environment sensitivity is the expensive part. A container image built for a dual-stack host answers one way; the same image on a host without IPv6 routing answers the other. So the protected build passes in staging, passes in one region, and fails in another, with a connection error against an address that appears nowhere in the configuration. Time gets spent on routing tables and firewall rules before anybody looks at a build setting.

It also composes badly with the resolution hints, which were renamed in the same arm. Those hints are what tell the resolver to return only families the host is actually configured for. Losing the family and the hints together means the lookup is answering the broadest possible question, and the answer is whichever address the system happens to order first.

The result changes shape, and the next line throws

The second arm is the most instructive in the file because it does not corrupt a value, it changes a type.

Asking a lookup for all addresses returns an array. The file records that shape and then builds a connection pool seed from it. With the key renamed, the lookup fell back to returning a single address as a string: the recorded shape went from an array of two to a string, and the very next line, which maps over the result, threw a type error.

That is a loud failure, and it is worth appreciating why it is loud when so much else in this series is silent. The value did not become wrong, it became a different kind of thing, and the code immediately after it was written for the other kind. Options that change a result's shape rather than its content are the ones most likely to fail in a way you will actually notice, which makes them the least dangerous members of this family.

The loudness is luck, though, and it is worth seeing where the luck runs out. This file maps over the result, which is a shape-sensitive operation. Code that indexes the first element, or checks a length, or spreads the result into an array, would have carried on with a single character where an address belonged. A string has a length and a zero index; it just does not mean what the caller thinks. The same rename against slightly different downstream code is silent.

The pattern generalises to any option that switches an API between one result and many: a database driver's row-versus-rows flag, a parser's single-versus-array mode, a glob library's first-match option. In every case the loudness of the failure is decided by the first operation performed on the result, not by the rename.

A socket with no timeout, waiting on a peer that will never answer

The third arm renamed the socket timeout. The peer in this measurement is a server that accepts the connection and then never speaks, which is what a wedged upstream looks like from the client side.

Before, the socket timed out and the run recorded that it had given up in under two seconds. After, the socket was still waiting when the measurement's own backstop fired at two and a half seconds, and the give-up check flipped to false.

Node sockets have no default timeout. As with the sandbox budget and the decompression cap measured alongside it, renaming this key does not lengthen the wait; it removes the deadline. A pooled connection in that state is not slow, it is retired from service while still counted as healthy, and the pool drains one connection per wedged upstream until there is nothing left to hand out.

The resolver arm is the same failure with a smaller blast radius. The file allows a dead resolver one try over 400 milliseconds; renaming the retry count restored node's four tries, and the operation that used to give up inside three seconds no longer did. A budget of one becomes a budget of four, and the request that depends on it inherits all of them.

Both arms illustrate why deadlines are the most dangerous option family in this whole series. A wrong value produces a wrong answer, which something eventually notices. A missing deadline produces no answer at all, indefinitely, and an operation that never returns cannot report that it has failed. Every layer above it, the retry wrapper, the circuit breaker, the health check, is waiting on a promise that will not settle, so each of those defences is disarmed by the same absence.

It is worth pairing this with the neighbouring measurements in this pass, because the same shape appeared three times independently: node's vm module has no default timeout, node's zlib has no default output cap, and node's sockets have no default timeout. In all three, the option name does not carry a limit, it carries the existence of a limit. Those are the names to protect first.

The inert arm, and what it does not prove

One arm in this file measured no change: the socket options that disable Nagle's algorithm and enable TCP keep-alive. That is not a result, and it should not be read as one.

This file never observes the behaviour those options govern. Nagle's effect appears in the latency of small consecutive writes, and TCP keep-alive appears as probe traffic on an otherwise idle connection, minutes later. Neither is visible in what this measurement prints, so the honest statement is that the arm was blind, not that the options are safe. Elsewhere in this series exactly that assumption has been wrong twice: an arm that looks inert usually means the measurement has not yet run the operation the name governs.

The loud arms bracket the file at the other end. Renaming the method that reads a listening server's address failed immediately with a type error naming a generated identifier, and renaming the port and host keys produced node's own missing-arguments error before any connection was attempted. Both are the good failure mode, and both are self-owned or call-shaped names rather than option names, which is the distinction that decides loud from silent throughout this measurement.

What to do about it

Protection alone changed nothing on any of the five profiles measured, so none of this argues against protecting a service that makes outbound connections. The exposure is member renaming with a pattern that reaches the names node's dns and net modules read.

Keep family, hints, all, verbatim, order, timeout, tries, noDelay, keepAlive, keepAliveInitialDelay, autoSelectFamily and autoSelectFamilyAttemptTimeout out of that pattern, and prefer anchoring the pattern to a naming convention you own.

Then assert on the connection rather than the configuration. A lookup test that requires family 4 and an address in the expected form catches the resolution arm. A test that connects to a socket which accepts and then stays silent, and requires the client to give up inside your budget, catches the timeout arm and is the single highest-value test on this page, because a missing socket deadline is the one failure here that takes a pool down rather than a request.

Frequently asked questions

Does obfuscation break DNS lookups or socket connections?

Not in the default configuration. A file that resolves a host for an IPv4-only service, asks for all addresses, connects to a silent peer under a socket timeout and queries a dead resolver under a retry budget behaved identically on all five protection profiles measured. These options become a surface only when member renaming reaches the names node reads.

Can member renaming change which IP address a service connects to?

Yes. With the address family and hints keys renamed, a lookup for a service reachable only over IPv4 returned the IPv6 loopback and reported family 6, where it had previously returned the IPv4 address and reported family 4. The lookup succeeds; it simply answers a question nobody asked.

What happens when a lookup is asked for all addresses?

The result changes type. Asking for all addresses returns an array; with that key renamed the lookup returned a single address as a string, and the next line, which maps over the result, threw a type error. Options that change a result's shape fail loudly, which makes them the least dangerous of this family.

Does renaming remove a socket timeout?

It removes the deadline entirely, because node sockets have no default timeout. Against a peer that accepts a connection and then never speaks, the socket had given up in under two seconds; after renaming it was still waiting when the measurement's own backstop fired. A pooled connection in that state is retired from service while still counted as healthy.

What happened to the DNS resolver retry budget?

It reverted to node's default of four tries over five seconds, from the one try over 400 milliseconds the file configures. The query against an address where nothing answers no longer gave up inside three seconds, and every request depending on that resolver inherits the longer budget.

Some socket options showed no change. Are they safe?

That is not what the measurement shows. The arm covering Nagle and TCP keep-alive was blind, because this file never observes the behaviour those options govern: Nagle appears in small-write latency and keep-alive appears as probe traffic minutes later. An arm that looks inert usually means the operation the name governs has not been run.

How do we test outbound connections on a protected build?

Assert on the connection. Require a lookup to return the address family you asked for, and stand up a socket that accepts and then stays silent, requiring the client to give up inside your budget. The second test is the highest-value one here, because a missing socket deadline takes down a pool rather than a request.

Related reading