What a browser cannot do
The web platform gives a page exactly one way to talk to the network: HTTP, through fetch or something built on it. There is no ICMP, no raw TCP, no control over IP headers. Every browser-mode diagnostic in this app is an HTTP request wearing a networking costume, and the UI says so at the point of use.
Four of those limits are collected as data in lib/browser-limits.ts rather than scattered through components, so a tool can say what it could not measure instead of reporting the limit as a finding: the CORS-safelisted response-header names, the Fetch Standard’s blocked-port table, whether the page can address the http: scheme at all, and the sentence that explains the last one to a user.
An HTTPS page cannot fetch http://
Section titled “An HTTPS page cannot fetch http://”The Mixed Content specification defines should fetching request be blocked as mixed content?. If the client is a secure context and the request URL is not a priori authenticated, the request returns blocked. fetch does not fail slowly with a timeout; it rejects, and no packet is sent.
Whether a URL counts as trustworthy is decided by is url potentially trustworthy? in Secure Contexts, which admits https:, wss:, file:, and loopback hosts, and rejects plain http: to a routable address.
Three places in the code act on this directly:
canFetchHttpSchemeis the one-line predicate (!pageIsHttps()), andHTTP_FROM_HTTPS_EXPLANATIONbeside it is the sentence a tool shows instead of a timeout: “This page is served over HTTPS, so the browser blocks plainhttp://requests as mixed content before they leave. Use anhttps://target, or the desktop app.”isMixedContentBlockedreturns true when the target ishttp:andwindow.location.protocolishttps:, andtestRTTrefuses before sending: “Browsers block HTTP endpoints when the app is served over HTTPS. Use an HTTPS test URL.”parsePingTargetgives a bare host the page’s own scheme instead of defaulting tohttp. The comment records the bug it fixed: defaulting tohttpmeant every probe on the deployed HTTPS site was blocked as mixed content before it left the browser, whilelocalhostover plain HTTP worked fine, so the failure was invisible in development.
parsePingTarget also carries the more interesting half of that fix. It returns insecureDropped: true when you explicitly typed an http:// target from an HTTPS page, so only the https:// candidate ran. The old code dropped the http:// probe silently and then labelled the surviving result “https”, which is a different measurement presented as the one you asked for. Two tests in tests/unit/browser-ping.test.ts hold the line in both directions: “says so when it had to drop the http probe the user asked for”, and “does not claim a drop when the user never asked for http”.
The browser port probe does the same for the same reason: an HTTPS page cannot probe http://host:8080 at all, so probePortOverHttp defaults its scheme to pageIsHttps() ? "https" : "http" rather than firing requests the browser refuses.
A cross-origin response exposes seven headers, and no more
Section titled “A cross-origin response exposes seven headers, and no more”When a page reads a cross-origin response, the Headers object does not contain what the server sent. Under the CORS protocol, a CORS filtered response excludes every header name that is not a CORS-safelisted response-header name. The Fetch Standard defines that set as exactly seven names, and the app hardcodes those seven as CORS_SAFELISTED_RESPONSE_HEADERS so the UI can print the list rather than paraphrase it:
| Header | Safelisted | What you actually learn |
|---|---|---|
Cache-Control |
yes | caching policy |
Content-Language |
yes | declared language |
Content-Length |
yes | body size |
Content-Type |
yes | media type and charset |
Expires |
yes | legacy expiry |
Last-Modified |
yes | legacy validator |
Pragma |
yes | legacy no-cache |
Strict-Transport-Security |
no | nothing |
Content-Security-Policy |
no | nothing |
Server, Location, Set-Cookie, everything else |
no | nothing |
Read the bottom half of that table carefully, because it is the trap. A server can opt extra names in with Access-Control-Expose-Headers, and almost none do. So a page that fetches a site cross-origin and reports “no HSTS header” is not reporting a fact about the server. It is reporting that it was not allowed to look.
Worked example: unknown is not absent
Section titled “Worked example: unknown is not absent”What a cross-origin fetch can see is one set intersection:
visible = present ∩ (safelisted ∪ exposed)
where: present = header names the server actually sent safelisted = the seven CORS-safelisted response-header names above exposed = names listed in Access-Control-Expose-Headers, usually none visible = names your JavaScript can read off the Headers objectNow run it against the seven headers gradeBlock actually scores. Their weights sum to 100:
| Header | Weight | Spec clause the code cites, linked to the clause itself |
|---|---|---|
Content-Security-Policy |
25 | CSP Level 3 section 3.1 |
Strict-Transport-Security |
20 | RFC 6797 section 6.1 |
X-Frame-Options |
15 | RFC 7034 section 2.1 |
X-Content-Type-Options |
10 | Fetch Standard, “determine nosniff” |
Referrer-Policy |
10 | Referrer Policy, header parsing |
Permissions-Policy |
10 | Permissions Policy header field, an RFC 8941 dictionary |
Cross-Origin-Opener-Policy |
10 | HTML, cross-origin opener policy |
X-XSS-Protection is assessed at weight 0 and reported as not-scored, so a site is neither rewarded for shipping a header no current browser implements nor penalised for having dropped it. That is the “does not count the deprecated xss filter either way” case in tests/unit/security-header-grade.test.ts.
Take a server that sets all seven correctly, and read them with a direct cross-origin fetch:
present = { Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy, Server, Date }safelisted = { Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma }exposed = { }
visible = present ∩ (safelisted ∪ exposed) = { } # no scored header is safelisted, and none was exposed
score = 0 of 100 # for a server that set every one of themgrade = FA checker built on a direct cross-origin fetch therefore returns F, 0 of 100, for every site on the internet, including the ones that got everything right. That is not a conservative result, it is a wrong one, and it is why the grade has to come from somewhere else.
The app’s answer is to not do the direct fetch. components/tools/security-headers.tsx and components/tools/http-headers.tsx each offer a paste panel that parses curl output, and a relay panel that asks a third party not subject to CORS, and both print the safelist inline so the reason is on screen rather than in these docs. Every relayed result is badged “unverified, via relay” rather than presented as first-hand, and the first-party command is printed for you:
# get the real headers yourself, following redirectscurl -sS -o /dev/null -D - -L https://example.comPresence is not effectiveness
Section titled “Presence is not effectiveness”The grader is worth one more paragraph, because reading a header is only half the problem. assessBlock scores a header effective only when its value does something, and the file comment names the three cases that used to score full marks for nothing:
| Header value | Verdict | Why |
|---|---|---|
Strict-Transport-Security: max-age=0 |
ineffective |
RFC 6797 section 6.1.1: zero deletes the stored policy |
Content-Security-Policy-Report-Only: ... |
ineffective |
a report-only policy is monitored, never enforced |
X-Content-Type-Options: sniff |
ineffective |
only the exact token nosniff has an effect |
X-Frame-Options: ALLOW-FROM https://example.com |
ineffective |
no current browser implements ALLOW-FROM |
Any HSTS header on a cleartext http:// response |
not honoured | RFC 6797 section 8.1: the browser ignores it outright |
The last row is a separate function, hstsIsHonoured, because a grade taken from an http:// response would otherwise overstate what a browser will actually do with it.
Historically the safelist had six entries; Content-Length was added later. If you see “six CORS-safelisted headers” in older material, that is why. The current normative list is the one linked above.
The browser refuses 83 ports before a packet leaves
Section titled “The browser refuses 83 ports before a packet leaves”This is the limit most browser-based port scanners never mention, and it is the one that quietly invents results.
Fetch Standard section 2.9, port blocking defines a bad port as any port listed in its table, and should fetching request be blocked due to a bad port? returns blocked for those. The table is there because a browser can be tricked into speaking almost-HTTP at a non-HTTP service, so the platform removes the classic service ports from reach entirely.
The table holds 83 entries, which is a number worth counting from the living standard rather than trusting to a docs page including this one:
# 83curl -sS https://fetch.spec.whatwg.org/ \ | tr -s ' \n' ' ' \ | grep -coE '<tr> <td>[0-9]+ <td>'The entries run from 0 to 10080, and the set is not one you can derive from first principles. 22 ssh, 25 smtp, 53 domain, 110 pop3, 143 imap, 6667 ircu and 10080 amanda are all in it. 445 microsoft-ds is not, nor is 3389 rdp, nor 1433 ms-sql. So “the browser blocks the dangerous ports” is not a rule; it is a list, and the only way to be right about a specific port is to consult it.
A fetch to a bad port rejects with a TypeError that is indistinguishable from a connection failure. So a naive probe sees the same exception for “port 22 is firewalled”, “port 22 is closed”, and “your own browser refused to send anything”, and a scanner that maps rejection to closed will confidently report closed for an SSH port that is wide open.
lib/browser-limits.ts carries the table as BLOCKED_PORT_SERVICES, keyed by port with the service keyword as the value, and probePortOverHttp checks it before constructing a request:
if (isBlockedPort(port)) { return { port, service, state: "browser-blocked", method: "http-probe", detail: `The Fetch Standard's port blocking list refuses any fetch to port ${port} (${blockedPortService(port)}), so nothing was sent and the port's real state is unmeasurable here.`, }}"browser-blocked" is its own member of PortState rather than a flavour of "unknown", and the type’s own comment says why: this is the one negative case the app can prove without sending anything, so folding it into unknown would throw away the only certainty available.
Worked example: scanning 22, 80 and 8080
Section titled “Worked example: scanning 22, 80 and 8080”scan target = example.internal, ports 22, 80, 8080page scheme = https:, so probes go to https://example.internal:<port>
port 22 -> isBlockedPort(22) is true -> browser-blocked nothing sent. real state unmeasurable in a browser.port 80 -> not blocked, probe sent, transport resolves -> open "something accepted an HTTPS request on this port. the response is opaque, so its status and identity are unknown."port 8080 -> not blocked, probe sent, transport throws -> unknown "closed, filtered, non-HTTP service, TLS mismatch and content blocker all fail identically here."
summarizeStates -> { open: 1, closed: 0, filtered: 0, unknown: 1, browser-blocked: 1 }Read the closed: 0 carefully. The browser probe can never produce closed; that counter exists only so the same summary shape serves the desktop connect scan, which can. Four it cases in tests/unit/port-probe.test.ts pin this: “knows the fetch standard blocks the classic service ports”, “reports a blocked port as unmeasurable without sending anything”, “reports open only when the request actually completed”, and “reports unknown, never closed, when the probe fails”.
no-cors mode buys reachability, not information
Section titled “no-cors mode buys reachability, not information”A request can set request mode to no-cors, which skips the CORS check and returns an opaque filtered response: type "opaque", status forced to 0, header list empty, body unreadable. response.ok is false even for a real 200.
An opaque response is still evidence of exactly one thing: something completed a TCP connection, spoke HTTP, and answered. The app uses that, and only that:
| Tool | What no-cors proves |
What it cannot distinguish |
|---|---|---|
| Port probe (browser) | the port is open, because something answered |
closed, filtered, non-HTTP service, TLS mismatch and content blocker |
| Ping (browser) | the host answered an HTTP request, and how long it took | DNS versus TCP versus TLS versus server think-time |
| TLS reachability probe | the chain is trusted, because the browser refused otherwise | an untrusted chain from a DNS or routing failure |
Every row loses information going right, and the app reports that loss instead of guessing. The failure branch of probePortOverHttp returns "unknown" and carries the comment // never guess: this branch used to report Math.random() > 0.8 as "open". That is the single most important line in this app’s history: a scanner that returned a random result for four fifths of its probes looked, on a screen, exactly like one that worked.
components/tools/ssl-checker.tsx is equally direct: a browser still refuses an opaque fetch when the certificate chain is untrusted, so a completed opaque request is the only real TLS signal a page can get, and it cannot be told apart from a DNS or network failure.
Note also what the TLS tool reads instead. Certificate Transparency logs (RFC 9162 section 4) record what was issued for a domain, which is not the same as what the server is presenting right now. The tool says so rather than implying it inspected a live chain.
“Ping” in a browser is an HTTPS round trip
Section titled ““Ping” in a browser is an HTTPS round trip”There is no ICMP in the browser. RFC 792 Echo (type 8) and Echo Reply (type 0), and RFC 4443 section 4.1 for ICMPv6 Echo Request (type 128), describe messages below the transport layer; the type numbers live in the IANA ICMP Type Numbers registry. Sending one needs a raw socket, and the web platform exposes none.
So browser-mode ping does this instead, in timedFetch:
const startTime = performance.now()const response = await fetch(target.toString(), { method, mode, cache: "no-store", signal: controller.signal, credentials: "omit", /* ... */})const endTime = performance.now()return { duration: endTime - startTime, response }That is wall-clock time from just before fetch to the moment the fetch promise settles. Two details in that boundary are worth being exact about, because they cut in opposite directions:
- The response body is never read, so the number stops at the response headers rather than at the last byte. For a
HEADrequest there is no body anyway; for theGETfallback the transfer time of the body is excluded. - Everything before the first byte is included: DNS, the TCP handshake, the TLS handshake, and the origin’s own think time. That is the part that makes the figure incomparable to ICMP.
Two independent labels enforce that in the UI rather than in prose. The RTT panel carries a transport field typed as exactly "ICMP echo" | "HTTPS request", with the comment “the shared result type has no ICMP slot; transport carries the truth”. The ping tool uses the newer ProbeTransport union, "icmp" | "https-round-trip" | "http-round-trip", and describeTransport turns each into a sentence rather than a badge. The test that keeps them apart is titled, in full, “never calls an http round trip an icmp round trip”.
What is inside the number
Section titled “What is inside the number”T_browser = t_dns + t_tcp + t_tls + t_server + t_netT_icmp = t_net
where: t_dns = name resolution, paid on the first request to a host t_tcp = one round trip for the TCP handshake t_tls = one or two more round trips for an HTTPS target t_server = the origin's own request handling t_net = propagation and queuing delay, the only term both shareWorked with real numbers, for a host one round trip of 20 ms away:
t_net = 20 ms # the only thing ICMP measurest_tcp = 20 ms # one RTTt_tls = 40 ms # TLS 1.3, one extra RTT, plus certificate workt_server = 15 ms # a static file from a warm origint_dns = 0 ms # cached from a previous request
T_icmp = 20 msT_browser = 20 + 20 + 40 + 15 + 0 = 95 msratio = 4.75xThe ratio is not a constant you can divide out. It depends on TLS version, session resumption, whether the connection was already open, and how slow the origin is. That is why the two numbers are reported under different transport labels instead of being reconciled.
The measurement is still useful, and the code tries to make it honest rather than pretty:
| Behaviour | Value | Rationale |
|---|---|---|
| Default method | HEAD (RFC 9110 section 9.3.2) |
no body to transfer, so the number is closer to a round trip |
On 405 |
one retry with GET, warning recorded |
405 Method Not Allowed means HEAD is refused, not that the host is down |
| On a CORS failure | one retry in no-cors, warning recorded |
timing survives; status validation does not |
| Cache busting | _netdash_ts and _netdash_rand query parameters |
a CDN answering from cache measures the CDN, not the origin |
| Samples | 5 by default, capped at 50 in the RTT panel | enough for a median without hammering the target |
| Retries per sample | 3 attempts, backing off 200 ms per attempt | one dropped request should not become a lost sample |
| Statistics | median, p95 by nearest rank, jitter as population standard deviation | a mean over five samples is dominated by whichever one hit a cold connection |
calculateMedian averages the two middle values on even counts, and calculatePercentile uses nearest rank, sorted[ceil(p / 100 * n) - 1], so p95 of 10 samples is the 10th value rather than an interpolation between the 9th and 10th. Both are covered in tests/unit/network-testing.test.ts, including “averages the two middle elements for even counts” and “uses nearest-rank for p95 on a full range”.
Traceroute is not approximated at all
Section titled “Traceroute is not approximated at all”Traceroute works by sending packets with an increasing IP time-to-live field and collecting the ICMP Time Exceeded messages routers return when the TTL reaches zero, which RFC 1812 section 5.3.1 requires a router to do. A browser cannot set a TTL on anything, so it cannot discover a single intermediate hop.
The browser build therefore refuses, with a toast reading “Traceroute needs the desktop app”. The comment in the source says what it replaced: this used to return five hardcoded hops with random jitter. A refusal is a better answer than a plausible fiction.
The browser will not compute MD5, and the tool says so instead of shipping one
Section titled “The browser will not compute MD5, and the tool says so instead of shipping one”The hash generator is a pure-computation tool, so it is easy to assume it can do anything. It cannot, and the reason is the same shape as everything else on this page: the platform registers a fixed set of algorithms and rejects the rest.
Web Crypto section 18.4.4, normalizing an algorithm looks the requested name up in the registered algorithms for that operation and, on a miss, returns a NotSupportedError. For digest, the SHA registration supplies exactly four names:
Name passed to crypto.subtle.digest |
Registration | Offered | Status in the app |
|---|---|---|---|
SHA-256 |
18.11 | yes | secure, the default |
SHA-384 |
18.11 | yes | secure |
SHA-512 |
18.11 | yes | secure |
SHA-1 |
18.11 | yes | deprecated, kept because it is registered |
MD5 |
never registered | no | listed as unavailable, with the reason |
SHA-3, Keccak |
never registered | no | listed as unavailable, with the reason |
lib/hash.ts encodes both halves of that table. HASH_ALGORITHMS holds the four that work, each entry’s name being “the exact string SubtleCrypto.digest accepts”. UNAVAILABLE_ALGORITHMS holds MD5, SHA-3 and CRC32 with a sentence each, so the UI can show why a name is missing rather than leaving a user to guess whether it was an oversight.
The distinction the file comment draws is the one worth carrying away: SHA-1 is broken but specified; MD5 was never specified. SHA-1 stays, marked deprecated with the SHAttered collision cited, because you sometimes need it to check a checksum somebody else published. MD5 is absent because computing it would mean shipping a hand-written JavaScript implementation, and a hash the platform refuses to vouch for is not a capability this app wants to claim.
Four tests in tests/unit/hash.test.ts make that a gate rather than an intention, and the last of them is the interesting one because it tests the platform rather than the code:
- “lists exactly the four WebCryptoAPI digest algorithms”
- “offers no algorithm
crypto.subtlewould reject” - “names MD5 as unavailable with the reason, rather than claiming it”
- “confirms
crypto.subtlereally does reject the unavailable names”
A separate block hashes the empty string and compares against the published digests for each algorithm, so a refactor that silently changed the encoding of the input would fail rather than produce plausible hex.
What this means in practice
Section titled “What this means in practice”| You want | Browser gives you | You need the desktop app for |
|---|---|---|
| Subnet, VLSM, MTU, ACL, VLAN math | the real answer, offline | nothing |
| DNS records | full answers over DoH | queries against a DNS server on your LAN |
| Latency to a public HTTPS endpoint | a usable HTTP round trip figure | an ICMP RTT comparable to ping |
| Is this port open | open when something answers |
closed and filtered told apart |
| Is this port open, on port 22 | browser-blocked, nothing sent |
any answer at all |
| Path to a host | nothing | traceroute |
| What is on my LAN | nothing | ARP cache and interface enumeration |
| SHA-256, SHA-384, SHA-512, SHA-1 | the real digest, offline | nothing |
| MD5 of the same input | nothing, and it says why | nothing; the desktop renderer runs the same Web Crypto |
The pattern in that table is worth naming: everything a browser does well is computation, and everything it does badly needs control below HTTP. That is the whole reason the desktop build exists.