Vulnerability Research

The Empty Request That Erased Two Routes — Reconstructing Tailscale TS-2026-002

TS-2026-002 allowed an authenticated tailnet peer that could reach an explicitly exposed device web interface on TCP 5252 to send a route request selecting no operation; the old handler asked for neither route capability, preserved neither existing route group, and then gave two empty values explicit write authority, clearing the target's active exit node and advertised subnet routes.

A hand-drawn investigation scene in which a blank request card passes four checkpoints toward a routing desk, where an exit path and advertised subnet paths fade; a repaired guard stops a second blank card before it reaches the desk.
In this article

Research basisSource reconstruction from Tailscale's bulletin and documentation, the pinned v1.98.0 tree, vulnerable parent f7f8b0a0 , direct fix da0a2775 , authorization refactor f15a4f44 , and locally rerun handler regressions

SourceTailscale TS-2026-002 bulletin and product documentation / pinned v1.98.0 source and commit history / vulnerable and fixed regression tests / SOSEC source reconstruction

1 The request was only two bytes long

The request contains an opening brace and a closing brace: {}. It names no exit node, supplies no subnet, and raises neither of the two switches that tell Tailscale's device web interface which route group to edit. Place that request beside a node with real state—a selected exit node in one drawer and an advertised private prefix in another—and common intuition predicts nothing will move. On the affected handler, both drawers ended empty.

This is not a reconstructed victim diary with an invented clock or operator. It is the exact state transition reproduced from the public source immediately before the fix. The starting preferences are deliberately nonempty. The request is decoded through the real route type, accepted by the old permission predicate, translated into a MaskedPrefs update, and captured at the LocalAPI seam. What makes the result unsettling is not spectacle. It is how ordinary every step looks until the last two values are compared with the first two.

Before the call, the target can be using another Tailscale node as its internet exit while also advertising a private network to authorized peers. Those are separate jobs pointed in opposite directions. The first sends the target's own traffic outward through a chosen gateway. The second invites peer traffic inward toward prefixes reachable behind the target. The vulnerable preference edit clears the selected exit identifier and the complete advertised-route slice in one pass, so one tiny request can disturb both jobs at once.

The change does not prove that traffic was read, that a new route was installed, or that a remote shell appeared. Clearing a selected exit usually returns future traffic to the client's ordinary local egress; clearing advertisements can withdraw subnet and default routes as the control plane converges. The clearest public impact is availability and policy drift: internal destinations can disappear, an expected egress address can change, and monitoring that depended on that egress path may no longer see new connections.

Tailscale assigned this behavior TS-2026-002 and published the bulletin on May 13, 2026. The affected desktop clients are Linux, macOS, and Windows releases from 1.56.0 through builds before the fix in 1.98.0, with a crucial configuration condition: the optional device web interface must have been explicitly exposed for peer access. A default local convenience page is not enough. The source peer also needs a permitted network path to TCP 5252 and must complete the interface's login check.

That list matters because the blank card does not teleport to the route handler. It passes four real doors. An affected process must be running. Remote device-web access must be enabled. tailnet policy must allow the source identity to reach the target service. The source must complete the management login. TS-2026-002 begins after those doors, where the application is supposed to ask a fifth question: is this authenticated peer allowed to edit exit-node settings, subnet settings, or both?

1.1 Four doors delivered the card to a fifth lock

The device web interface is a client-management surface. On a supported desktop, an operator may use it locally; peer-facing management is an additional choice, commonly enabled through the documented web-client controls or a service arrangement that listens on the node's Tailscale address and port 5252. Asset inventories therefore need more than installed-version data. They need the running version, listener mode, start method, bind address, and the period during which remote access was enabled.

Network permission is the next door. Tailscale's grants model separates packet reachability in the ip portion from application authority in the app portion. A source can possess an application capability and still have no network path to the service. It can also reach TCP 5252 and authenticate while holding only a restricted set of device-web features. The vulnerability lives in that second arrangement: the broad management path works, while the route-specific capability decision is supposed to limit what happens next.

The login check contributes identity, not universal administration. Ownership, tags, and grants influence who can manage a device and which feature groups are available. For the route page, the relevant application values include exitNodes and subnets, with a documented wildcard able to cover broader access. Other web-interface abilities govern unrelated controls. The old route predicate was meant to connect each requested operation to its corresponding feature.

A normal exit-node edit raised SetExitNode, so the predicate asked for exitNodes. A normal advertised-route edit raised SetRoutes, so it asked for subnets. The empty object raised neither switch. The predicate therefore asked no route-capability question and returned success. Authentication was still real, and the four earlier doors still constrained exposure, but the feature lock had been bypassed by presenting it with an operation the code had never named.

This explains why removing route capabilities was not a sufficient temporary mitigation. The vulnerable shape selected no capability, which was the whole problem. Disabling peer-facing web access removes the service path. Restricting 5252 reduces possible sources. Updating the running client repairs the handler. Tight feature grants remain valuable for ordinary actions, yet they cannot by themselves correct an old predicate that never asks for either feature when both selectors are false.

It also explains why a scanner that reports “Tailscale 1.90” cannot decide the case. Version identifies a candidate. Remote enablement tells whether a service path existed. Historical policy tells which identities could reach it. Login or management-session evidence tells which peer crossed the fourth door. Preference and route records tell whether the two drawers changed. Each record answers a different question, and no single one should be stretched into the whole story.

A useful fleet view gives each candidate one row and those questions separate columns. Record running-build confidence, web-listener confidence, policy-path confidence, login confidence, old-route confidence, and observed-change confidence. A node can move quickly to “patch now” while remaining “exploitation unknown.” That distinction lets response teams close code exposure without manufacturing incident certainty, and it keeps the search for historical evidence alive after the urgent upgrade.

Prioritization follows the role of the target as well as reachability. A developer laptop with no route state can contain the vulnerable handler but experience no visible clear. A production subnet router can withdraw access for many peers. A device that both selects a controlled exit and advertises critical prefixes holds the two conspicuous values used in the reproduction. Patch all qualifying assets, but recover and monitor the route-bearing ones first because their empty drawers create the largest operational effect.

A hand-drawn sequence showing an affected desktop, a lit remote-management control, a policy bridge, and an authenticated key before a two-drawer routing console; a separate application-capability latch below the path remains unasked by a blank card.
Figure 1. The affected build, remote web enablement, TCP 5252 reachability, and a completed login deliver the request to the handler. The missed decision comes next: the blank card selects neither route capability even though the downstream write still touches both route groups.

1.2 The two missing routes point in opposite directions

The first drawer contains ExitNodeID. When populated, it identifies the node this client has chosen for internet egress. Clearing the identifier disables that selection. New connections can leave through the ordinary local network, acquire a different public source address, use another DNS path, or miss an inspection point the organization expected to sit on the selected exit. Existing flows may fail, survive briefly, or reconnect, depending on timing and application behavior.

The second drawer is AdvertiseRoutes. Ordinary private prefixes announce networks available behind this client. The paired IPv4 and IPv6 default prefixes represent the client's offer to act as an exit node for others. Clearing the slice removes both kinds of advertisement from local preferences. As state propagates, approved peers can lose internal reachability, and a node that previously offered exit service can stop offering it. An approval retained in the control plane cannot advertise a route the client no longer sends.

One machine can hold both roles at the same time. It may use a hardened cloud exit for its own outbound traffic while advertising a laboratory subnet to the tailnet. That is why “the route was cleared” is too vague for response. Investigators need the old selected exit, the default-route pair, every non-default advertised prefix, and the approval state attached to those prefixes. Recovery can require restoring one drawer, the other, or both.

The visible symptom can lag behind the preference edit. A local write occurs on the target first. The control plane then learns that advertisements changed, peers receive new state, and their route tables converge. A user may report an unreachable subnet after the management request, while another cached connection continues briefly. Analysts should keep the preference timestamp, the first peer-visible withdrawal, and the first application failure as separate events. Distributed systems rarely make one configuration change appear everywhere at one instant.

Benign administration can produce the same end values. An owner may intentionally stop using an exit node, retire a subnet, rebuild a router, or run automation with an empty desired configuration. The public indicator is therefore behavioral: a qualifying management path followed by an unexpected two-group clear on an affected running build. The story becomes credible when source identity, session, request timing, old preferences, new preferences, and route consequences agree.

Now the opening puzzle has its precise shape. Four doors explain how the card can arrive. Two drawers explain what disappears. The missing piece is how a request that selected no drawer acquired permission to empty both. To find it, the investigation has to move backward through the endpoint's history, to the moment a full replacement API learned how to perform partial edits without changing the stamp it placed on its final write.

2 The endpoint changed jobs without changing its final stamp

The vulnerable code can be patched with a small guard, but the defect did not begin as one careless line. It accumulated while the endpoint changed jobs. In November 2023, the route page behaved like a full replacement form: submit the desired exit and advertised routes, then replace both groups together. A month later it learned selective edits. In January 2024 it learned feature permissions. The final write still looked like the original full replacement.

Commit history turns that sequence into five scenes. ecd1ccb9175dce235bb5e77d0e0d610655867a74 introduced the route surface. 97f8577ad28c6c9fcc68cdade89a6b51adfb67d4 added the two operation selectors and logic to carry old values forward. 9aa704a05d4c446668898265c5597ed4c51295ec attached application capabilities to the selectors. da0a277565dcc9661cef732434b5a2ae61f86009 rejected an empty selection and repaired preservation. f15a4f441621e537340234d63271d3cf4dffe4b5 moved independent capability checks into the handler.

Reading only the fixed diff makes the first repair appear obvious. Reading the history explains why the empty state could remain invisible. Each feature was designed around requests the web page produced. The page selected exit settings, subnet settings, or a deliberate combination. It did not send an operation with both selectors down. The JSON decoder could still create that state from public input, and the backend still had to decide what it meant.

2.1 The first workshop replaced both drawers every time

In the original representation, there were no SetExitNode and SetRoutes switches. The request carried route values, the handler calculated the desired advertised prefixes, and the output object marked both route-related preferences as present. An empty configuration meant “replace both groups with empty values.” That behavior was broad, yet its input and output models agreed: the form described a complete desired state and the backend wrote a complete desired state.

The selectors arrived to support a more convenient job. A user changing an exit choice should not have to resubmit every advertised subnet. A user changing a subnet list should not accidentally reset the chosen exit. The route request therefore acquired two booleans that identified which group the screen intended to edit. To feed the still-broad backend update, the handler read current preferences and copied the untouched group into the outgoing values.

That adapter was a workshop with two drawers and one press. Select the blue drawer, copy the red drawer's contents, then stamp both as the new complete state. Select the red drawer, copy the blue drawer, then stamp both. For those two paths, the result is correct. But two booleans create four combinations: blue only, red only, both, and neither. The first UI flows exercised a subset of that table. The public decoder admitted all four.

The preservation code expressed the UI assumption directly. If exit-node fields were selected, it copied current non-exit advertisements. Else, if subnet routes were selected, it copied the current selected exit and default-route intent. The branches answer “which other drawer should I copy?” They do not answer “what happens when no drawer was selected?” In that fourth row, both branches remain silent and the request keeps its zero values.

The write constructor never learned about the new fourth row. It continued to set ExitNodeIDSet and AdvertiseRoutesSet to true, because a broad preference PATCH still needed both groups for the valid single-edit paths. The selectors governed preservation, while the masks governed presence. No invariant connected them. The endpoint had become partially selective at the front and fully assertive at the back.

Capability checks then attached to the same selectors. This was sensible for named operations: exit-node work needs exitNodes; subnet work needs subnets. Yet the permission helper inherited the same incomplete table. It denied a selected action when the matching feature was absent, and it accepted a request when neither denial condition ran. The fourth row now skipped both preservation and permission while retaining the two-field write stamp.

No component needed to be malicious or obviously broken. Go decoded absent booleans to false. The permission predicate checked requested features. The preservation branch copied state for expected UI paths. The route calculator returned an empty result for empty input. MaskedPrefs applied fields whose masks were true. The defect appeared in the handoff between those honest components, where “no selected operation” was never converted into a server decision.

Five hand-drawn workbench scenes show a full two-drawer replacement console, selector levers, permission keys, a blank card entering an overlooked slot, and a repaired guard that stops blank cards before the write press.
Figure 2. The endpoint evolved from full replacement to selective editing and selective authorization. The empty combination appeared in the protocol but never received a safe meaning until the repair added an outer guard.

2.2 The tests followed the screen and missed the decoder

UI-centered tests naturally covered the controls users could click. Choose an exit and see it change. Edit subnets and see the selected exit survive. Submit both and see both update. Those checks were useful; they proved the screen and handler agreed on ordinary requests. They did not enumerate every value the public request type could represent. A wire protocol has a larger state space than the interface that usually produces it.

The safest fixture for a partial update begins with conspicuous state. Give every protected field a different nonzero value. Send each selector combination. Record the exact output masks, output values, number of preference reads, number of preference writes, and returned status. If a fixture begins with empty preferences, a destructive clear and a true no-op look identical. TS-2026-002 becomes undeniable only when a real exit ID and a real subnet vanish.

The four selector combinations should have been introduced with the selectors themselves. Zero operations needs an explicit outcome. Exit only must preserve subnets. Subnets only must preserve exit selection and exit advertisement. Both must apply both requested groups and require both capabilities. Once capabilities arrive, cross each row with missing, partial, wildcard, and complete grants. The result is a small table, not an unbounded fuzz problem.

Error assertions need side-effect assertions beside them. A test that expects an error can still pass if the handler writes first and fails later. The fixed upstream fixture captures whether the fake LocalAPI received a PATCH, so the empty row is not merely “returns an error”; it is “returns an error and performs zero preference writes.” That second clause is the security property the opening story demands.

Contract tests should live beside the public request type, not only beside the frontend component. The frontend test owns which body a button emits. The handler test owns every body the decoder accepts. The LocalAPI recorder owns the translation into masks. A higher-level integration owns process wiring and route outcomes. When each layer has one job, a new selector cannot quietly expand the decoder's state space without also changing the handler table.

Code review can make that coupling visible with a simple question on every protocol-field change: which new combinations are now representable? Adding one boolean doubles the theoretical combinations even when the screen exposes only one new control. Reviewers do not need to test meaningless combinations forever; they need an explicit normalization rule that collapses them into valid operations or visible errors. The rule then shrinks the rest of the system's state space.

History also prevents a shallow backport. Adding only the empty guard closes the published row, but an old else if permission helper can still treat a two-operation request as if only one capability matters. Copying only the later permission refactor without repairing preservation leaves the blank card dangerous. A downstream tree needs the effective contract from both repairs: nonempty operations, independent authorization, independent preservation, and no write on denial.

Release archaeology adds one more clock. A commit date shows when source changed. A tag shows which tree contains it. A channel shows when users could install it. A process start time shows what actually ran. The fixed source anchor is v1.98.0, but that version was used as a test or release-candidate build; the first stable 1.98 release was 1.98.1, and its Linux package was withdrawn for an unrelated MagicDNS regression before 1.98.2 corrected that problem. Operations should install a current supported fixed release, not chase the historical fix tag in isolation.

By the end of the history, the blank card has acquired a biography. It was born when selective controls expanded the request type, ignored because the screen never produced it, waved through when permissions followed only raised selectors, and still stamped as a full replacement because the backend adapter kept its original shape. The next step is to watch the card enter the decoder. Three different envelopes will emerge as the same two false booleans.

3 Three envelopes become the same unnamed operation

The public request type is compact. Two booleans select the protected route groups; the remaining fields hold an exit choice, exit-advertisement intent, and subnet prefixes. Compactness is not the flaw. The trouble is that an ordinary Go boolean remembers only true or false after decoding. It does not remember whether false came from an omitted member, an explicit false, or a body containing only a member the struct did not recognize.

type postRoutesRequest struct {
    SetExitNode bool
    SetRoutes   bool
    // exit-node and advertised-route values follow
}

For the operation selectors, these three bodies reach the handler in the same state: {}, {"setExitNode":false,"setRoutes":false}, and—when unknown members are ignored—{"misspelledSelector":true}. They can come from hostile intent, a client bug, version skew, or a typo. By the time authorization runs, their origin is gone. The server sees SetExitNode=false and SetRoutes=false.

A zero-length HTTP body is different. The JSON decoder returns end-of-file before this handler receives a request object, so “empty request” in this investigation means an empty operation selection represented by valid JSON. That distinction keeps the story tied to the code. It also shows why strict unknown-member rejection is helpful but incomplete: it can reject the misspelled envelope, yet {} and two explicit false values still require a semantic decision.

3.1 Decoding erased the history of false

Changing the booleans to pointers could preserve absent-versus-present information. It would not automatically decide whether two present false values are a valid operation. An enum or an operation list can make combinations easier to name. It still needs a rule for zero elements and for combined elements. The durable repair is a validation step immediately after decoding: construct the intended operation set, prove it contains at least one recognized operation, then use that same set for every later decision.

The direct fix chooses the smallest expression of that rule. If neither SetExitNode nor SetRoutes is true, return a client error. It does not infer an operation from a nonempty value elsewhere in the body. A caller that supplies prefixes but forgets to raise SetRoutes receives a visible failure. Silent guessing would let data fields acquire authority outside the explicit protocol and would make typos harder to diagnose.

Unknown-member rejection remains useful for quality. It can tell a newer or broken client that a name was not understood. Duplicate-member handling also deserves a documented rule because decoders and intermediaries do not all choose the same winner. Neither concern should replace the central invariant. Whatever wire spelling survives decoding must become one of a small number of valid server operations before the handler reads or modifies preferences.

Compatibility errors should be observable without echoing the full body. A stable machine-readable error category for “no operation selected” helps a mismatched client stop retrying, while a request correlation identifier lets operators join the failure to a session. Count these failures by client version and source identity. A sudden rise can reveal a broken rollout or deliberate probing; a low background rate can reveal old automation that the previous silent acceptance concealed.

The local vulnerable fixture makes that invariant measurable. It pins the parent of da0a2775, decodes each of the three envelopes into the real request type, and confirms both selectors are false. Existing preferences contain a nonempty exit-node ID and 192.168.1.0/24. The real web handler is connected to an in-memory LocalAPI recorder. No public listener is opened, no external tailnet is joined, and no third-party node is contacted.

Before exercising the three zero-selection bodies, the fixture sends a normal exit-only request and confirms that the existing subnet survives. This positive control rules out an ineffective starting state, a recorder that fails to preserve updates, or a route calculator that always returns empty. All three zero-selection cases then follow the same route: the handler reports no error and emits one preference PATCH with ExitNodeIDSet=true and AdvertiseRoutesSet=true, an empty exit identifier, and a zero-length route slice. Because the fixture starts with two conspicuous values, the result proves a destructive transition. Repeating the test on July 16, 2026 produced the expected vulnerable result in every subcase.

This is stronger evidence than observing a quiet UI. It binds wire representation to decoded selectors, selectors to permission behavior, and handler output to concrete write masks. It also stops at a controlled seam. There is no need to send a state-changing request at a production device to understand the defect. The source path and the locally captured update already answer the technical question.

3.2 A permission check asked zero questions and heard no denial

The old capability predicate was written as a sequence of conditional denials. If SetExitNode is true and the peer cannot edit exitNodes, deny. Otherwise, if SetRoutes is true and the peer cannot edit subnets, deny. If neither condition fires, allow. That pattern works only when an earlier guarantee says at least one selector is true and combined operations receive complete treatment.

With the blank card, both antecedents are false. The predicate never calls the route-feature checks and reaches its success result. This is sometimes described as a vacuous truth: every requested operation was authorized because the set of requested operations was empty. That would be harmless if an empty set caused zero state changes. The downstream translator breaks the equivalence by producing two writes anyway.

The source still needs a valid management identity. The issue is not anonymous access to the device web page. It is an application authorization mismatch: the session is known, feature grants are available, and neither route feature is selected for review even though both route groups later change. This difference is important for logs. A successful login proves identity at one layer. It does not prove that the feature-level decision matched the eventual side effect.

The old mutually exclusive form also complicates a request with both selectors true. A peer holding one route capability can satisfy the first branch and avoid an independent check of the other, depending on the exact historical predicate. The later handler-local refactor uses two separate checks, so a combined edit must present both capabilities. TS-2026-002's bulletin centers on the empty selection, but the final contract has to cover every row the two switches can express.

A secure authorization helper should not discover operations by walking a handful of flags and defaulting to success when it finds none. It should receive a validated operation set. For each element, resolve the required feature. Deny if any feature is missing. Send the same set to state-preservation and write construction. The authorization result and the side-effect plan then share one vocabulary, which makes contradictions testable.

Wildcards need the same discipline. A broad * grant can legitimately satisfy both checks, yet audit output should still name the concrete operations performed. “Wildcard session succeeded” is less useful than “exit selection and advertised routes were changed under wildcard authority.” Concrete operation records help administrators shrink a broad grant later and help responders distinguish an ordinary authorized edit from the old zero-selection path.

At this point the request has crossed authentication without claiming a route feature. It still has not changed state. Its value fields remain ordinary Go zero values, and a cautious translator could discard it as a no-op. The destructive part comes next, in a read-modify-write adapter that tries to preserve whichever drawer the screen did not select. With no selection, it preserves neither—and then sends both empty drawers to a backend that faithfully obeys its masks.

4 Seven ordinary stations turn blank into durable state

No source line says “clear both route features when the request is empty.” The result is assembled across several files and abstractions. That is why stopping at the permission predicate gives an incomplete explanation. The blank request must be followed through current-state extraction, preservation, route calculation, mask construction, the LocalAPI PATCH, and final preference application. Each station hands the next one a value that is locally reasonable and globally wrong.

The recurring objects from the opening help. The request is a blank envelope. The two selectors are levers resting down. Permission sentries remain silent. Preservation drawers stay closed. A route spool produces no cord. A press raises two seals above empty trays. The final preference store removes the two old cords from their hooks. The cause becomes visible only when all seven objects remain in the same frame.

4.1 The handler reads old state and then fails to carry it forward

servePostRoutes() begins by reading current preferences. It has the selected ExitNodeID and a single AdvertiseRoutes slice. The slice mixes two meanings: ordinary prefixes are advertised subnets, while the paired IPv4 and IPv6 default routes mean this node offers exit service. The handler divides that slice so it can preserve exit-advertisement intent separately from non-exit prefixes.

This decomposition is necessary for valid selective edits. If the caller changes only the exit group, the outgoing broad update still needs the existing subnets. If the caller changes only subnets, it needs the old selected exit and the old default-route pair. The adapter reads a partial intention and reconstructs a complete route state for the more general preference API.

The vulnerable preservation branches are positive and exclusive. When SetExitNode is true, current non-exit routes are copied into the request's working values. Else, when SetRoutes is true, the current selected exit and exit-advertisement flag are copied. Both false means neither copy occurs. The old preferences have been read, classified, and then abandoned.

After that missed handoff, the working request contains zero values: an empty exit identifier, a false exit-advertisement flag, and an empty subnet list. Those values have not yet been assigned to the target. They are simply what Go provides for fields that the request omitted. The next station treats them as a complete desired configuration because the handler does not carry operation presence into its output masks.

The route calculator receives a comma-joined route string and the exit-advertisement boolean. It validates and masks supplied prefixes, checks paired default routes, and can add the two defaults when exit advertisement is requested. For an empty string with the boolean false, the correct calculated result is an empty route slice. The calculator has no access to previous preferences and no reason to invent them.

Changing the calculator to preserve hidden state would damage its contract. It is a parser and normalizer, not an authorization or persistence engine. It also could not restore the separate selected exit ID. The repair belongs in the adapter: reject an operationless request and supply current values for every unselected group before asking pure helpers to calculate new values.

4.2 Two raised seals make empty values commands

The handler constructs a MaskedPrefs value after route calculation. In condensed form, the important shape is simple: the embedded preferences hold the empty exit ID and empty route slice, while both presence masks are true. This is the sixth station, where zero values stop being passive defaults and acquire explicit write authority.

MaskedPrefs{
    Prefs: Prefs{
        ExitNodeID:     "",
        AdvertiseRoutes: nil,
    },
    ExitNodeIDSet:      true,
    AdvertiseRoutesSet: true,
}

The local client sends that object through the preference PATCH. The LocalAPI endpoint decodes the request and passes the masked edit into the backend preference path. Validation can reject malformed prefixes or disallowed state, and policy may modify particular settings on some deployments. None of those general layers can infer that two true masks came from a request selecting zero operations. The translator has already discarded that history.

Prefs.ApplyEdits implements the mask contract consistently. If a corresponding FooSet field is true, assign Prefs.Foo, including its zero value. For the selected exit, empty is the legitimate representation of “do not use an exit.” For advertised routes, an empty slice legitimately means “advertise none.” The backend does exactly what a legitimate clear operation requires.

That fidelity is why describing MaskedPrefs as broken would miss the root cause. A partial-update type must be able to clear values. If it silently ignored empty strings and slices, administrators could not disable features through normal edits. The web adapter is responsible for granting masks only to operations that survived validation and authorization. Here it granted two masks to an empty operation set.

Some target policy can affect an exit-node change. A centrally managed exit choice may constrain whether a particular edit is accepted. That is a deployment-specific modifier, not a fleet-wide cure, and it does not establish universal protection for advertised subnets. Remediation should use the vendor's affected range and fixed handler as the common basis, then record local policy as additional evidence for each target.

The final persistence step updates profile preferences and triggers the normal route consequences. The selected exit disappears from the target. The advertised-route slice becomes empty. Health and control-plane state can follow. Nothing in those later stages knows the original request was {}. By then, two authorized-looking masked fields are all that remains.

Audit placement changes what investigators can prove. Logging only after persistence captures the two clears but may omit the original selector state. Logging only at request entry captures false-false but may not prove a write occurred. A useful record is created from the normalized plan and completed with the mutation result: source peer, target, selected operations, required features, output mask names, result, and correlation identifier. Values can be redacted independently from the structural contradiction.

Failure ordering matters too. Preference validation, policy enforcement, persistence, route recomputation, and control-plane notification may sit in different functions. A test at the LocalAPI seam proves the web adapter issued the command, while a test at the final store proves durable state. For the fixed empty row, both seams should remain quiet. For a valid row, the record should show one deliberate mutation followed by the expected route update, never a speculative write followed by rollback.

The controlled reproduction stops at a particularly useful witness: the captured LocalAPI request. It shows both true masks and both empty values before platform-specific routing consequences introduce noise. That witness proves the adapter created a destructive command. Fixed tests can then invert the expectation: an empty selection must produce an error and the recorder must remain untouched.

Seven hand-drawn mechanical stations show a blank envelope, two lowered selectors, silent permission sentries, closed preservation drawers, an empty route spool, two raised write seals above empty trays, and two existing route cords removed from their hooks.
Figure 3. The failure is compositional. Decoding supplies zero selectors, permission asks no feature question, preservation copies no old state, calculation returns no routes, and two true masks turn the empty values into persistent clears.

The seven stations answer the opening “how,” but they leave one deeper question. Why does an empty value sometimes mean “ignore this field” and sometimes mean “erase the field”? The answer is not inside the value. It is carried by the seal beside it. Understanding that four-case truth table is the shortest path from this specific defect to a reusable engineering rule.

5 The seal decides whether emptiness is harmless or destructive

MaskedPrefs separates a field's value from a field's presence in an edit. The embedded Prefs contains ordinary Go values. Companion masks such as ExitNodeIDSet and AdvertiseRoutesSet say whether those values participate in the update. This is a deliberate solution to a PATCH problem: users must be able to leave a field alone and also set that field to its legitimate zero value.

Think of a tray and a wax seal. The tray may contain a route cord or be empty. With no raised seal, the store ignores the tray and preserves the old hook. With a raised seal, the tray becomes a command: a cord replaces the old cord; an empty tray removes it. Looking only at the contents cannot distinguish omission from clearing. Authority sits in the seal.

5.1 Four cards explain the entire preference contract

Each preference has four conceptual combinations. Two keep old state, one installs a new nonempty value, and one explicitly clears. The last row is not an exceptional hack. It is a necessary operation for settings that can be disabled. TS-2026-002 becomes possible because the web adapter produces that last row twice without a matching input operation.

Presence maskSupplied valueEffect on existing preference
falsenonemptyPreserve; the supplied value is outside this edit
falseemptyPreserve; zero has no authority without the mask
truenonemptyReplace with the supplied value
trueemptyExplicitly clear the existing value

A tempting patch would suppress empty values when constructing masks. That would hide the reported symptom and break valid controls. Selecting “no exit node” is supposed to clear ExitNodeID. Removing all advertisements is supposed to write an empty route slice. Separate endpoints or sentinel values could express those actions, but the existing masked type already expresses them cleanly. The caller must supply authorized intent.

The web request has its own presence system. SetExitNode=true means the exit-related values belong to the operation. SetRoutes=true means the advertised-subnet values belong to it. A correct translator uses that operation selection to drive validation, feature requirements, state preservation, and output. The vulnerable translator used selectors for some of those tasks and assigned the final masks unconditionally.

We can describe the contradiction with two sets. Let O contain the route operations selected after decoding. Let M contain the preference groups authorized for output. A simple design wants O nonempty, every member of O authorized, and M to contain no unexplained group. The vulnerable path has O = ∅ and M = {ExitNodeID, AdvertiseRoutes}.

The current adapter writes preserved values for an unselected group, so valid single-operation requests can still have two true masks. In that architecture, the extra member of M needs a proof: its outgoing value equals the current value read for that group. Another design could set only the selected masks and avoid a read-modify-write. Both designs can work. The dangerous design is one where broad masks appear without selected authority or proven preservation.

Four hand-drawn evidence cards combine an active or inactive presence seal with a route tray that is full or empty; the active seal paired with an empty tray is highlighted as the combination that removes old state.
Figure 4. Empty is not automatically omitted. The presence seal controls the edit: an active mask above an empty value is a legitimate clear, so the security decision must happen before that mask is raised.

5.2 One operation model must survive every handoff

The cleanest implementation creates a normalized operation set once. Wire fields are decoded and validated. Zero recognized operations are rejected. Each selected operation is matched to a capability. Unselected groups are preserved or omitted. Cross-group rules are checked. Output masks are derived from that same plan. Audit records name the operations and resulting state groups. Persistence receives no new interpretation to invent.

This does not require a large framework. Two operations can be represented by two validated booleans, an enum set, or a small internal struct. The important property is that code after validation cannot accidentally forget which operations were approved. Passing the raw request through multiple layers invites each layer to rediscover intent from different fields, which is how false became “no permission needed” at one station and two true masks at another.

Concurrency deserves attention because preservation is a read-modify-write. Suppose an exit-only request reads current subnets, another authorized process changes those subnets, and the first request later writes its preserved snapshot with a true route mask. The older snapshot can overwrite the concurrent change even after the empty-selection bug is fixed. Version checks, narrower masks, serialized preference updates, or conflict detection can reduce that class. The public repair preserves the existing architecture, so deployments should keep ordinary configuration concurrency in their tests.

A preservation proof should name its snapshot. If the preference API exposes a revision, the adapter can submit the revision it read and retry or report a conflict when state moved. Without revisions, tests can at least interleave two controlled edits and document last-writer behavior. This concern is separate from TS-2026-002, yet the defect exposes why broad masked writes deserve that scrutiny: an adapter can faithfully reapply a value that was current a moment ago and still erase a newer authorized change.

Static review can find suspicious translators. Flag constructors that set several FooSet masks unconditionally while input selectors are conditional. Property tests can assign unique nonzero values to every preference, generate selector and capability combinations, and assert that unselected groups remain invariant. Mutation tests can remove the nonempty guard or invert one preservation condition and require the suite to fail. A compact truth table becomes executable memory.

Diagnostics can expose the contradiction without logging sensitive values. Record the operation names, selected capability names, and output mask names; redact node IDs and prefixes where policy requires it. A trace saying “operations: none; masks: ExitNodeID, AdvertiseRoutes” would have made the mismatch visible during development. A trace saying “operation: subnets; preserved: exit; masks: both” gives reviewers the proof required by the broad adapter.

The seal metaphor returns us to the source. The backend did not mistake an empty tray for a full one. It saw two raised seals and obeyed them. The repair must therefore stop the blank card before state is read, then ensure every valid card carries the keys and preservation instructions its selected levers require. That is exactly what the two public repair commits do.

6 The repair stops the card before it can touch a drawer

The direct security patch does two jobs in a few lines. First, it rejects a request when both selectors are false, before current preferences are read or any PATCH is prepared. Second, it rewrites preservation as two independent rules: if exit settings are not selected, carry the current exit state forward; if subnet routes are not selected, carry the current non-exit routes forward. One guard closes the empty row. Two negative conditions make the valid rows complete.

The later authorization refactor brings the feature checks into servePostRoutes() and evaluates each selected operation independently. An operationless request receives a client error. A selected operation without its corresponding feature is denied. A combined request needs both route capabilities. Most important, every denial occurs before the preference write seam, so the fake LocalAPI sees no PATCH.

6.1 The first condition gives zero a visible meaning

Commit da0a2775 begins with a statement the old protocol lacked: callers must specify SetExitNode or SetRoutes. This converts the decoder's false-false state into an explicit invalid operation. It covers an omitted pair, two explicit false values, and any other accepted body that leaves both recognized selectors down. It does not wait for route calculation or inspect whether value fields happen to look nonempty.

Rejecting before GetPrefs() is useful beyond performance. An invalid request cannot observe timing shaped by current route complexity, cannot race with a state read, and cannot leave misleading audit entries for a preference edit that should never exist. The strongest regression expects no LocalAPI call at all. A visible 400 response helps clients correct their request; zero downstream writes protects the node.

After the guard, the fixed preservation logic describes each drawer on its own terms. If SetExitNode is false, restore the current selected exit and current default-route advertisement intent into the working values. If SetRoutes is false, restore the current non-exit prefixes. Both statements can be understood without knowing which other selector is true. That local clarity is what the earlier positive branch lacked.

For exit only, requested exit values survive while current subnets are copied. For subnets only, requested prefixes survive while the current selected exit and exit-advertisement pair are copied. For both, neither preservation rule runs and both requested groups are applied. The early guard prevents neither from reaching this stage. Every one of the four selector combinations now has a defined outcome.

The repair leaves CalcAdvertiseRoutes and MaskedPrefs intact. That limits collateral change. The calculator still turns an intentional empty configuration into no advertised routes. The mask type still supports legitimate clearing. The web adapter now ensures those general tools receive either a validated edit or preserved state. A narrow patch is safe here because its regression matrix covers the wider protocol behavior.

Backports should port these properties, not only the textual diff. A downstream product may wrap the handler, rename request fields, control exits through policy, or add a third route group. Maintainers need the nonempty guard, a preservation rule for every unselected group, independent authorization for every selected group, and a zero-write assertion on all errors. Code that compiles after a cherry-pick can still preserve the old behavior through local divergence.

6.2 The regression table makes every lever and key observable

The upstream TestServePostRoutes fixture starts with a stable exit-node ID and a subnet. The empty case expects an error and no captured preferences. The exit-only case chooses a new exit and expects the original subnet to survive. The route-only case supplies 10.0.0.0/8 and expects the original exit to survive. The combined case expects both requested groups. Nontrivial starting values make preservation and clearing distinguishable.

Commit f15a4f44 adds denial cases around those state rows. A request selecting exit settings without exitNodes is rejected. A request selecting subnet routes without subnets is rejected. The checks are separate, so selecting both operations with only one key does not pass. API tests also verify the visible response behavior, including the distinction between malformed operation selection and missing feature authority.

On the pinned v1.98.0 source, the focused web tests were rerun with the route handler, API, and peer-capability cases. Empty-request denial, exit-only preservation, route-only preservation, combined update, capability conversion, and both missing-capability cases passed. The vulnerable parent fixture was rerun separately and continued to produce the two true empty masks, giving a controlled before-and-after contrast.

Tests should count more than the preference PATCH when architectures grow. An invalid request may trigger an audit record, notification, cache invalidation, or control-plane update before a later error. Decide which of those are acceptable, then assert the count and order. For this defect, the irreducible condition is simple: false-false produces zero preference mutation, and a missing route capability produces zero preference mutation.

A hand-drawn repaired workshop shows a blank two-selector card stopped by a guard, one-selector cards changing one drawer while preserving the other, a two-selector card requiring two keys, and a missing-key branch rejected before the write press.
Figure 5. The fixed contract is total: zero selected operations stop at the outer guard; one operation changes its drawer and preserves the other; two operations require two keys; every rejection leaves the write press untouched.

A source fix is not yet a fixed fleet. Version 1.98.0 contains the repair but was a test or release-candidate build; 1.98.1 was the first stable 1.98 release, and its Linux package was withdrawn for an unrelated MagicDNS regression before 1.98.2 corrected that problem. Operators should install a current supported fixed version for their platform and channel, not pin a historical source anchor. Vendor backports may use another version string, while a custom build can advertise a high version and still omit the commit.

After installation, verify the process that owns the service. Join fixing revision or equivalent backport, package or image provenance, signature where available, artifact digest, runtime build identifier, process start time, and restart result. Containers need task-replacement evidence, and every standby needs checking because failover can restore the old handler. An appliance that bundles Tailscale needs its vendor's patch statement; neither an upstream-style version nor a green package manager proves the old process left memory.

Finally, run the harmless side of the matrix on an authorized test node. A zero-operation request through a lab or integration harness must return a visible error with no preference change. Exit only, routes only, and both must produce the expected state from conspicuous starting values, while missing capabilities fail before a write. Source, artifact, process, and behavioral evidence together show that the repaired guard is actually in front of the desk.

7 A fleet cannot recover from a version number alone

Response begins with an intersection, not a list of every Tailscale installation. A candidate target needs an affected running build, peer-facing device web enabled, a source-to-target path on TCP 5252, and a source able to complete the login check. If any door was demonstrably closed throughout the period, the published route cannot reach the handler. If historical evidence for a door is missing, mark it unknown and continue; today's configuration cannot rewrite yesterday's exposure.

Version history may come from endpoint inventory, package records, image digests, update logs, and process telemetry. Web enablement may be recorded in service arguments, client preferences, unit files, management scripts, configuration audit logs, or listening-socket history. Policy revisions and identity history establish which peers could reach the service. Ownership, tags, group membership, and posture can change, so evaluating only the present grant file can erase an earlier path or invent a new one.

7.1 Reconstruct exposure and state before changing either

Preserve the route baseline before upgrading or disabling services. Capture the selected exit-node ID, whether the target advertised both default routes, every non-default advertised prefix, and each control-plane approval. Also record peer-visible route state from a known authorized client. The target's local preferences describe its intention; the control plane describes received and approved advertisements; peers describe convergence. All three views are useful when they disagree.

Correlate management contact with preference changes. Strong pivots include source peer identity, target node, TCP 5252 flow, login-check or management-session event, route-handler audit, daemon preference update, service restart, and route withdrawal. General WireGuard traffic is not an indicator of this request. A connection to 5252 proves reachability, not a POST. A successful login proves session identity, not a destructive edit. Preference change supplies the state witness.

Secondary symptoms can select a time window. The target may obtain a different public source address after losing its exit selection. DNS behavior can change. Peers may lose private-prefix reachability after an advertisement disappears. Health status or admin-console records may reflect withdrawal and reconvergence. Each symptom has benign causes, so use it to find the relevant preference and session records, not as a standalone attribution.

Decision records should distinguish four outcomes. “Not affected” needs proof that the running build or service path was absent. “Affected, path not reachable” needs historical network evidence. “Reachable, no observed change” needs preference and route telemetry covering the period. “Change observed” still needs session and source context before it becomes attributed exploitation. This vocabulary prevents an upgrade ticket from silently becoming an incident conclusion or an evidence gap from being labeled clean.

Normalize time carefully. Endpoint logs, configuration audits, flow telemetry, and user reports can use different zones or clocks. Retain original timestamps, record observed drift, and separate the instant the preference was written from the instant peers changed routes. If a source session precedes the two preference clears and peer-visible effects follow, the sequence is compelling. If only an end state survives, treat exploitation as unresolved while still restoring service.

Do not probe live third-party systems to fill missing evidence. The public behavior changes configuration and can cause the outage under investigation. Use an owned lab tailnet, an isolated handler fixture, or a designated test device with console access and a restoration baseline. The vulnerable source reproduction already proves the mechanism; production work should concentrate on exposure, state, and recovery.

7.2 Restore the intended roads in a controlled order

The following sequence keeps evidence and availability from competing with each other:

  1. Inventory the running code and remote service. Record platform, client version, package or image revision, process start time, web enablement, listener address, and TCP 5252 reachability from relevant identities.
  2. Freeze the route baseline. Save selected exit, paired default advertisements, every subnet prefix, route approvals, and peer-visible routes before an upgrade or service change alters the picture.
  3. Rebuild the access path. Preserve policy revisions, node ownership, tags, group membership, login evidence, configuration audits, and flows for the period in question.
  4. Contain peer-facing management when it is unnecessary. Disable the remote web path using supported controls and verify that peers can no longer reach 5252; keep an alternate management route for critical routers.
  5. Install and verify a current supported fixed release. Replace or restart the active process, check standbys, record artifact provenance, and prove the old handler is no longer serving.
  6. Restore each routing intention explicitly. Re-select the required upstream exit, restore both default advertisements where this node offers exit service, reapply every approved subnet prefix, and confirm control-plane acceptance.
  7. Test convergence and the repaired contract. Verify intended egress and DNS, peer reachability and denial, restart persistence, false-false zero writes, single-group preservation, combined authorization, and monitoring coverage.

Critical subnet routers need out-of-band access before route or package work begins. Update a non-serving member first when redundancy exists, observe route distribution, move a small set of traffic, then continue through the group. A standby on old code is not closed exposure; it is deferred exposure waiting for failover. A rollback plan that reinstalls an affected binary should automatically reapply the remote-web disablement and network restriction.

A backup must prove that it can restore the intended routes, not merely that a file exists. Export a test node's current routing intent, restore it in an isolated environment or maintenance window, then verify the selected exit, paired default advertisements, subnet prefixes, and approval relationships. Local preferences without the matching control-plane approvals can return an unreachable configuration; approvals without local advertisements leave the control plane with no route to distribute. Preserve and test both sides as a pair.

Restore the two route directions separately. For clients required to use a central exit, select the correct exit and confirm external source address and DNS behavior. For nodes that offer exit service, restore the IPv4 and IPv6 default pair. For subnet routers, restore each canonical prefix and verify approval. Test from authorized peers and from identities that should remain denied. A green field in the admin console does not prove end-to-end forwarding.

Route overlaps and high-availability choices can alter the apparent result. Two routers may advertise the same prefix. Removing one can leave connectivity intact while reducing redundancy; restoring it can change path preference without changing reachability. Capture the expected router set, priority behavior, and failover design before declaring recovery complete. The same applies to exit nodes chosen by policy: record whether local selection is allowed and which control is authoritative.

If an immediate update is impossible, disabling peer-facing device web access is the strongest direct containment. Verify the listener from the peer side and watch for configuration management that may turn it back on. Narrow grants or ACLs to the smallest management set, but remember why feature removal alone was incomplete. Give every temporary exception an owner, expiry, monitoring rule, and scheduled upgrade.

A hand-drawn seven-stage recovery loop shows fleet inventory, route-state recording, access-path review, a clean software package, verification of the running daemon, restoration of gateway and subnet routes, and closure of unnecessary management access around an unchanged two-drawer regression test.
Figure 6. Recovery preserves evidence first, closes the vulnerable path, verifies the running fix, restores both route directions, and ends by proving that a blank request leaves both drawers unchanged.

Monitoring should keep the investigation's causal vocabulary: newly enabled peer-facing web, unexpected reachability to 5252, an unusual peer login, route-preference changes, loss of paired default routes, multiple advertisements clearing together, and divergence between local intent and control-plane approval. Join those signals by target and time. Audit records may hash node IDs and prefixes, but should retain whether an exit was cleared, whether default advertisements changed, how many subnets changed, which feature authorized the action, and which source identity initiated it.

Coverage needs denominators: clients with known running versions, web listeners with owners, route-bearing nodes exporting preference changes, management sessions linked to peer identity, policy revisions retained for the investigation window, and critical routers with tested restoration baselines. Track the delay from preference mutation to an explained ticket. These measures show whether the next blank card will leave a reconstructable path instead of a generic “Tailscale changed” event.

Recovery closes when four proofs agree: the running process contains the fix; local preferences express the intended exit and advertisements; authorized peers see the expected routes while forbidden peers do not; and a zero-operation regression returns a visible error with zero writes. Assign them respectively to endpoint engineering, tailnet administration, application owners, and detection engineering, while the incident lead records unresolved history. A short acceptance record with owner, timestamp, evidence, and expiry is more durable than a single “patched” checkbox.

8 The blank card must end with zero writes

TS-2026-002 is a small defect with a long path. JSON decoding turned omission into false. Authorization treated false as no route feature to check. Preservation treated false only inside branches built for another selected operation. The route calculator returned a valid empty result. Two unconditional masks then said both empty values were present edits. The final store obeyed. Security failed in the translation between meanings, not in a magical dangerous zero.

The repaired handler creates one coherent sentence from those fragments: a request must select at least one operation; every selected operation needs its own capability; every unselected state group is preserved; combined values must be valid; and denial causes no preference write. The regression table records that sentence for zero, one, and two selections. It is short enough to review and strong enough to survive refactoring.

8.1 Design the operation before designing its JSON

Administrative APIs should begin with verbs. Here the verbs are “change exit-node state” and “change advertised routes.” For each verb, define required authority, accepted values, cross-group constraints, affected backend preferences, audit fields, and rollback behavior. Decide whether verbs can be combined and what zero verbs means. Only then choose booleans, enums, objects, or lists for the wire representation.

Normalize immediately after decoding. Resolve aliases and unknown members according to a documented policy. Reject an empty or contradictory operation set. Do not consult mutable state until request meaning is valid. Pass an internal operation plan to authorization and persistence, so later code cannot reconstruct intent from a different subset of raw fields. The plan can remain tiny; its value comes from being shared.

Authorize every operation independently. Helpers that contain several conditional denials should have an explicit precondition that the operation set is valid and nonempty. Combined operations should accumulate requirements instead of choosing the first matching branch. Broad grants may satisfy several requirements, but audit output should expand them back into the concrete operations they enabled.

Choose a partial-update strategy deliberately. Narrow masks can write only selected groups and reduce stale-state overwrite. Broad masks can preserve existing architecture, but every unselected group must be copied from a consistent snapshot and protected against conflicts. In both designs, output presence must be explainable from authorized intent or proven preservation. Unconditional masks deserve a reviewer who can state that proof.

Put unique nonzero markers in every preference, generate the cross-product of operation selection and capabilities, and assert status, normalized operations, required features, output masks and values, write count, and final state. Cover malformed JSON, zero operations, explicit false values, unknown and duplicate-member policy, one operation, both operations, and stale snapshots where the architecture permits them. Measure the side effects in order as well: a 400 after a preference write or a 401 after a control-plane notification is still destructive. Naming the invariant “zero writes before error” makes the property harder to lose during refactoring.

Keep source and release evidence joined. Record the fixing commit, the first source tag, stable-channel history, package or image provenance, running process, and focused regression result. An upstream green test does not prove a downstream artifact contains the code. An installed artifact does not prove the old process stopped. A process label does not prove a custom build matches upstream. Each link is cheap to record during release and expensive to reconstruct during an outage.

Apply the same method outside route management. Account editors, firewall consoles, cloud-policy forms, and device-management APIs often translate selective screens into broad backend objects. Whenever a request type gains selectors, list zero, one, and combined operations. Whenever a backend gains masks, prove each raised mask comes from authorized intent or preserved state. Whenever a handler gains feature permissions, compare the checked operation names with the final side-effect names. The objects change; the contradiction is the same.

8.2 Let the same objects return at the end

The four doors from the opening remain useful controls. Keep clients current. Expose device web management only where it has an owner and operational reason. Limit TCP 5252 to intended identities. Require and audit the management login. Then maintain the fifth lock: route operations need their corresponding application capabilities, including combined requests. No one door replaces the others because each stops the card at a different point.

The two drawers remain useful evidence. Preserve selected exit, default advertisements, non-default prefixes, and approval state as distinct fields. Monitor a two-group clear as a high-value event, especially when the source lacks route features or the request has no named operation. Keep enough history to reconstruct ownership, tags, policy, listener state, and running code at the time. A present-day screenshot cannot answer a past access path.

The two seals remain useful review signals. Make output-mask names observable in tests and carefully redacted diagnostics. Ask why each active mask is allowed and where its value came from. If a mask represents a selected operation, point to its capability decision. If it represents preservation, point to the snapshot and conflict rule. If neither explanation exists, the write has acquired authority by accident.

The repaired guard supplies the final acceptance test. Present a valid JSON object with both route selectors down. The handler should reject it before reading mutable preferences, no LocalAPI PATCH should appear, no route audit should claim a change, and both existing drawers should retain their conspicuous values. Then raise one selector and verify the other drawer survives. Raise both and verify two keys are required. That is the whole story turned into a durable suite.

The original blank card was dangerous because every station assumed another station had assigned it meaning. The decoder assumed the handler would validate combinations. The permission helper assumed selected flags described all effects. Preservation assumed the screen chose a known path. The mask constructor assumed its values represented authorized desired state. The backend assumed true masks were trustworthy. The repair makes one component own the meaning and lets every later component enforce the same decision.

A request containing only {} should not be memorable. After the fix, it is not. It stops at the outer guard, returns a visible error, and leaves the selected exit and advertised subnets exactly where they were. The most satisfying ending is deliberately uneventful: the card never reaches the desk, the two seals stay down, and both roads remain on the map.

Research record

9Evidence, objects, and sources

The material below preserves the identifiers and references used in this report.

9.1Research objects

Products, actors, techniques, affected objects, and control points discussed in the report.

BulletinTS-2026-002

Tailscale device web-interface route capability bypass

Affected versionsTailscale 1.56.0 through before 1.98.0

Linux, macOS, and Windows clients with peer-facing device web access explicitly enabled

EndpointPOST /api/routes on TCP 5252

Authenticated device web-interface route preference update

Direct fixda0a277565dcc9661cef732434b5a2ae61f86009

Reject empty selection and preserve every unselected route group

Authorization refactorf15a4f441621e537340234d63271d3cf4dffe4b5

Handler-local per-operation capability checks

Route capabilitiestailscale.com/cap/webui: exitNodes, subnets

Feature grants associated with the two protected route groups

9.2Event chronology

  1. Route endpoint introduced

    Commit ecd1ccb added the route-management surface with broad preference masks.

  2. Operation selectors added

    Commit 97f8577 enabled single-group edits but did not reject a zero-operation request.

  3. Feature capabilities attached

    Commit 9aa704a checked grants only for selectors that were true.

  4. Direct fix committed

    Commit da0a277 rejected empty selection and repaired preservation logic.

  5. Authorization moved into handlers

    Commit f15a4f4 added independent feature checks and explicit HTTP errors.

  6. TS-2026-002 published

    Tailscale documented prerequisites, affected platforms, impact, and remediation.

  7. Request path and recovery plan reconciled

    SOSEC reconciled the request path, source history, fixed regressions, release history, and recovery plan into one causal chain.

9.3Sources and material

  1. Tailscale security bulletin TS-2026-002https://tailscale.com/security-bulletins#ts-2026-002
  2. Tailscale device web interface: access, login, ownership, and web UI capabilitieshttps://tailscale.com/docs/features/client/device-web-interface
  3. Tailscale grants overview: network and application permissionshttps://tailscale.com/docs/features/access-control/grants
  4. Tailscale grants syntax referencehttps://tailscale.com/docs/reference/syntax/grants
  5. Tailscale exit-node deployment and route semanticshttps://tailscale.com/docs/features/exit-nodes
  6. Tailscale subnet-router configuration and approvalhttps://tailscale.com/docs/features/subnets/subnet-routers
  7. Tailscale client preference managementhttps://tailscale.com/docs/features/client/manage-preferences
  8. Tailscale configuration audit logshttps://tailscale.com/docs/features/logging-streaming/configuration-audit-logs
  9. Tailscale automatic client updateshttps://tailscale.com/docs/features/client/automatic-updates
  10. Tailscale changelog and 1.98 release-channel historyhttps://tailscale.com/changelog
  11. Original web route endpointhttps://github.com/tailscale/tailscale/commit/ecd1ccb9175dce235bb5e77d0e0d610655867a74
  12. Route operation selectors and preservationhttps://github.com/tailscale/tailscale/commit/97f8577ad28c6c9fcc68cdade89a6b51adfb67d4
  13. Selector-dependent web UI capabilitieshttps://github.com/tailscale/tailscale/commit/9aa704a05d4c446668898265c5597ed4c51295ec
  14. Direct empty-selection fix and route-handler regression matrixhttps://github.com/tailscale/tailscale/commit/da0a277565dcc9661cef732434b5a2ae61f86009
  15. Handler-local authorization and denial regressionshttps://github.com/tailscale/tailscale/commit/f15a4f441621e537340234d63271d3cf4dffe4b5