Browser-Based Port Scanning in the Age of LNA
Is it possible ? Yes. How ?
Because in this case… timing is everything.
Background
The Pop-Up
Have you seen this prompt recently?
If you use a modern browser, there’s a good chance you’ve either seen it, or you’re about to.
This dialog was introduced as part of Local Network Access (LNA). The premise is straightforward. Public websites should not be able to silently reach into your local network and talk to private resources, such as your router admin panel, your local dev server, your NAS, a Raspberry Pi on 192.168.x.x. LNA draws a hard boundary between public IP space and private/loopback address space, and requires explicit user permission before a cross-boundary fetch is allowed to proceed.
What LNA is meant to do
Under the hood, this boundary is enforced by a function such as URLLoaderThrottle (in Chrome and Chromium). When a public origin tries to access a of a less public IP the following sequence plays out:
-
Request initiatedfetch() or XHR call is made to an internal IP or localhost
-
IP resolvesHostname resolved; address classified as private or loopback
-
LocalNetworkAccessCheck triggersA TCP connection is opened to the target IP:Port to determine reachability
-
Request deferred — user promptedIPC message sent to browser process; prompt appears if port is open
-
User decidesAllow, Block, or ignore the prompt
-
Request resumes or failsPreflight sent on Allow; CORS error on Block; fetch remains Pending if ignored
Importantly :
The fetch promise remains in a pending state throughout the entire pause. From the page’s perspective, the request is simply waiting. From a timing perspective, that wait is information.
LNA enabled browsers
As of February 2026, LNA is available on the following browsers :
| Browser | LNA Support | Version | Status — Feb 2026 |
|---|---|---|---|
| Chrome / Chromium | Supported | Chrome 142+ · Sept 2025 | Full LNA enforcement |
| Microsoft Edge | Supported | Edge 142+ · 2025 | Full LNA enforcement |
| Firefox | In trials | Nightly only | In development |
| Safari | Not supported | N/A | Partial / different model |
The Finding
Port Scanning
Here is the crux of the finding as part of this research. To decide whether to show you the prompt at all, the browser first has to check whether the private target’s port is actually reachable. It does this by opening a TCP connection, before any permission is granted or denied.
The implication is quiet but significant. The port state has already been determined before the user has taken any action :
- Port Closed : the TCP handshake gets an RST almost instantly, and the fetch rejects in milliseconds. No LNA prompt is required.
- Port Open : the TCP handshake succeeds, the browser defers the request, the prompt appears, and the fetch sits in a pending state for as long as the user takes to decide, or indefinitely if they ignore it.
This creates a trivially observable timing differential:
The malicious page does not need the user to click Allow or Block. The timing delta between an immediate rejection and a prolonged pending state is sufficient to fingerprint port state.
State of LNA Prompt vs. Port State
| Prompt State | Port State |
|---|---|
| "Allow" | Port can be probed |
| "Block" | Port cannot be probed |
| Pending prompt | Port can be probed |
Based on this its safe to say that LNA may be intended to protect knowledge of such port states when the user “Blocks” LNA.
Proof of Concept
Basic Test
Start a local HTTP server on any port :
python3 -m http.server 30000
Open a browser’s developer console from any public-origin page. Paste the following. Importantlly, when the LNA prompt appears, do not click Allow or Block. Simply observe the console output.
// Target port to probe
const port = 30000;
// Allows us to cancel the request after a timeout
const c = new AbortController();
// Record the start time
const t = Date.now();
// Abort the request after 2 seconds
setTimeout(() => c.abort(), 2000);
// If the request times out (AbortError), the port is open — closed ports fail instantly
fetch(`http://localhost:${port}`, { mode: "no-cors", signal: c.signal })
.catch(e => console.log(e.name === "AbortError" ? `open (${Date.now() - t}ms)` : "closed"));
Scaling to the Full Port Range
An optimized version can sweep the full 65,535 TCP port range using batched, concurrent fetches with tuned abort timing can be found here :
An example run:
Implications
Port scanning via browsers is not new. What LNA changes is the quality of the signal. The LNA probe is a deliberate TCP handshake with a binary outcome, producing a clean, reliable timing split.
Browser & User Fingerprinting
Different OS and software configurations expose different port signatures. Combined with existing fingerprinting signals, a port map assembled in seconds meaningfully contributes to cross-session tracking.
Internal Network Reconnaissance
The graver scenario is enterprise. An employee whose browser bridges the public internet and an internal network is an ideal unwitting proxy. A malicious page can use LNA timing to probe RFC 1918 ranges to map live hosts and open services, with no installation, no privileges, and no interaction beyond a page load.
The Prompt Is Not the Defence
It is worth stating directly: LNA’s prompt was never intended to prevent port discovery. It was intended to prevent unauthorized data exchange with local resources. The port-state leakage described here is a side-effect of the mechanism used to decide whether to show the prompt, and not a flaw in the prompt itself.
Summary
LNA’s TCP probe, the mechanism that decides whether to show the prompt, runs before the user sees anything. That probe leaks port state. Open ports stall, closed ports reject instantly. The delta is measurable from JavaScript with no special permissions.
Allow, Block, or ignore : it doesn’t matter. By the time the prompt appears, the scan is already done.