Skip to content

feat(reality): find REALITY targets from a device on the users' ISP - #7048

Open
Plus98ir wants to merge 7 commits into
MHSanaei:mainfrom
Plus98ir:feat/reality-finder
Open

Plus98ir wants to merge 7 commits into
MHSanaei:mainfrom
Plus98ir:feat/reality-finder

Conversation

@Plus98ir

@Plus98ir Plus98ir commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A "From my internet" tab in the REALITY target scanner. It finds targets next to the server and checks them from a device on the users' ISP, including whether the ISP cuts each name as SNI. The panel server sends no probes for it.

REALITY finder

Why

The current scanner probes from the panel's server. It can tell whether a target speaks TLS 1.3 + h2 + X25519, but not whether the users' ISP filters the name as SNI, and in filtered networks that is what decides whether a target works. Scanning many neighbours from a VPS can also be read as network scanning by the host (the concern raised on #6950).

How it works

Like the clean IP scanner's full test (#6950), the panel hands out a one-day command. It runs a standard-library Python script (3.8+) on the admin's phone (Termux) or PC, with the VPN off:

  1. It reads the certificates of the server's neighbours (its /24, /22, /20, announced prefix or whole AS, via RIPEstat) and takes the site names from them.
  2. It checks each name the way REALITY needs it: TLS 1.3, a ClientHello offering only X25519, h2, and a certificate valid for that name.
  3. It sends each name as SNI to the inbound's own port, as a client would. A name the ISP cuts is marked filtered. An SNI router in front of the port (every name cut except the current target) is detected, and the filter result is dropped instead of marking everything filtered.
  4. It scores the targets (open on this ISP, same /24 or AS, not a CDN, DNS points at that IP, handshake time) and reports back with the token alone.

The report appears in the dialog by itself. Use puts target and serverNames into the form, and the admin saves as usual. A report from a VPN or a datacenter network is flagged, because its filter result says nothing about the users.

Abuse / safety

  • The server makes no outbound probes for this. It stores a command and receives reports.
  • Commands live 24 h, at most 50 at a time and 20 reports each. A report keeps at most 100 targets. Every field is validated (IPv4, host-name pattern, clamped numbers, known CDN names, control characters stripped). The report body is capped at 256 KB.
  • The public realityfinder/run/:token/{script.py,report} routes work only with an unexpired token and can only add a report to that token's job.
  • The server value must be a public IPv4 or a host name. It is written into the script as a JSON string literal.

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Documentation
  • Tests only
  • Build / CI / tooling
  • Other

Areas affected

  • Frontend (UI / panel pages)
  • Backend (API endpoints, login, settings)
  • Xray config generation
  • Subscription (share links / Clash / JSON)
  • Statistics / traffic counters
  • Database / migrations
  • Install / upgrade script
  • Docker image
  • Multi-node (sub-nodes)
  • Telegram bot

How was this tested?

  • internal/web/service/reality_finder_test.go: rejected servers (private, IPv6, injection attempt), bad port and scope, the values written into the script, the 24 h expiry, report sanitizing and ordering, the report and command caps, and the User-Agent in the command. I broke each guard once to confirm that a test goes red.
  • frontend/src/test/reality-finder-panel.test.tsx: 443 is used for an inbound behind a local listener, the current target name is sent, a report renders and Use gives name:443 + [name], the VPN warning, and the report coming back after the dialog is reopened (not after the command expires). Also mutation-checked.
  • End to end on a local panel built from this branch: made a command in the UI, ran it on Windows against 140.82.121.3 (GitHub /24), 4 targets came back, and Use filled Target and SNI (screenshot above).
  • On two live panels (Iran and Turkey, one behind Cloudflare). This found two bugs that are fixed here:
    • Cloudflare answers Python's default urllib User-Agent with 403 (error 1010), so the command now sends one.
    • Picking a target closed the dialog and lost the report. The command is now kept per inbound in the browser tab until it expires.
  • go test ./..., golangci-lint run (0 issues), npm run lint, npm run typecheck, the vitest unit and components projects (1951 tests), and npm run build all pass. make gen shows no drift, and the docs OpenAPI copy and server.mdx are regenerated.

Breaking changes

None. Existing scanner behavior is unchanged, and no DB or config change is needed.

Checklist

  • I tested the change locally and confirmed the described behavior.
  • I added or updated tests for the new behavior (when applicable).
  • go build ./... and the test suite pass locally.
  • For frontend changes: npm run lint, npm run typecheck, and npm run build pass.
  • I updated the Wiki / README / API docs if user-facing behavior changed.
  • My commits follow the project's existing message style.
  • I have no unrelated changes mixed into this PR.

The target scanner probes from the panel's server, so it cannot tell
whether an ISP cuts a name as SNI, and scanning many neighbours from a
VPS can be read as network scanning by the host.

A new "From my internet" tab in the REALITY target scanner hands out a
one-day command (like the clean IP scanner's) that runs a standard
library Python script on the admin's phone or PC, with the VPN off:

- it reads the certificates of the server's neighbours (its /24, /22,
  /20, announced prefix or AS),
- checks each name the way Xray needs it: TLS 1.3, an X25519-only
  ClientHello, h2 and a certificate valid for the name,
- sends the name as SNI to the inbound's port to see whether the ISP
  cuts it, and spots an SNI router in front of the port,
- scores the targets (open here, same /24 or AS, not a CDN, DNS match,
  handshake time) and reports them back with the token alone.

The panel server makes no outbound probes for this; it only stores the
reports (24 h, 50 commands, 20 reports each, 100 targets per report,
every field validated). "Use" puts the target and its SNI into the
form, and the admin saves as usual.
Picking a target closes the scanner, and the report was lost with it:
adding a second target meant a new command and a new scan. The command
is kept per inbound in this browser tab until it expires, and the
scanner no longer drops its state when it closes.
A panel behind Cloudflare answered the one-day command with 403 (error
1010): Cloudflare refuses Python's default urllib User-Agent. The
command now names one; the script's own requests already did.
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

@MHSanaei blocking: the one-day command runs whatever Python the panel URL returns, and on a panel reached over plain HTTP (or through a TLS proxy that sends no X-Forwarded-Proto) that code travels unencrypted across the ISP the admin is told to run it on.

Feature: Fits with gaps. This adds a "From my internet" tab to the REALITY target scanner. The panel makes a 24-hour command; the admin runs it with Python on a phone or PC on the users' ISP, with the VPN off. The script scans the server's neighbouring IPs for usable REALITY targets, tests each name as SNI through the inbound's port to see whether the ISP filters it, and posts a ranked list back for the admin to pick from. The panel already has a scanner, but it probes from the server and cannot see ISP SNI filtering, so this capability is new. It has three gaps. On a panel served over HTTP, the command fetches and runs code with no encryption or integrity check, over the exact network the feature treats as hostile. For an inbound deployed to a node, the default server is the master, so the scan and the filter test hit the wrong machine. Picking a target here also leaves the previous target's scan verdict on screen.

Blocking — 1 HIGH / 2 MEDIUM / 1 LOW open (4 new), 0 pre-existing

Reviewed head: 4472237

Evidence. No linked issue; the claim comes from the title, body and three commits. Base has no device-side scan: the only scanner posts to /panel/api/server/scanRealityTargets from the master (frontend/src/pages/inbounds/form/useSecurityActions.ts:163-174). On the head, the authenticated routes (internal/web/controller/reality_finder.go:17-22) sit behind checkAPIAuth + CSRF (internal/web/controller/api.go:186-192). The public routes (reality_finder.go:62-67, registered at internal/web/web.go:268) are reachable only with a 144-bit random token (internal/web/service/reality_finder.go:157-161), expire after 24 h (:199-205), cap the body at 256 KB (reality_finder.go:80), and clean every reported field before storing it (service/reality_finder.go:264-328). The five script values are written as JSON string literals or an integer (:219-229), and the server value has to pass the IPv4/host-name check first (:121-128), so I found no way to inject into the generated script. The endpoint chain is complete: endpoints.ts has both routes, the six structs are in StructAllow, and docs/public/openapi.json is byte-identical to frontend/public/openapi.json on the head (compared with cmp). Go tests: TestRealityFinderAddReportKeepsOnlyCleanTargets pins the exact cleaned and ordered rows, and TestRealityFinderCommandsAreCapped pins eviction of the oldest job. These are real assertions (read, not run). Overlapping work: #6950 (same author, open) brings in the same one-day-command pattern for the clean IP scanner, and #6967 (same author, open, REALITY watch) also edits frontend/src/pages/inbounds/form/security/reality.tsx, so the two will collide there. I found no merged commit that already does this. Nothing in the PR text or files tries to steer this review.

Design. The script body is fixed at build time and only five values change per command. The trust model could therefore rest on something the admin can check, not only on the transport the browser happened to use. Keep in mind that the person running it is on a network that injects into plaintext traffic. The filter test assumes REALITY passes an unlisted SNI on to its dest, and that the dest then completes a handshake for a name it does not host. I could not verify the first half: xtls/reality is not in the unpacked module tree. Where the dest refuses unknown names, which many CDN fronts do, every name comes back cut. The router heuristic then drops the result and labels the setup an "SNI router" when there is none. A panel on a self-signed certificate cannot serve the command at all, because the one-liner's default urlopen verifies the certificate (inferred). Jobs live only in process memory, so a panel restart in the middle of a scan makes the script fall back to saving a local file. That is acceptable for a 24-hour command, but operators will run into it. If #6950 merges too, the token store, caps and command builder will exist twice.

Findings

HIGH — internal/web/service/reality_finder.go:186 (with internal/web/controller/reality_finder.go:25-31). The command this PR adds runs exec(u.urlopen(u.Request('<scriptURL>',…)).read()), so whatever bytes come back from scriptURL run as Python with the user's privileges on the admin's phone or PC. requestBaseURL makes that URL http:// unless the Go server terminated TLS itself or the request carries X-Forwarded-Proto: https. So it is plaintext whenever the admin opens the panel over HTTP, which the panel supports, and also when the panel sits behind a TLS-terminating proxy that does not add that header (nginx does not add it by default). The tab's own text tells the admin to run the command with the VPN off on the users' internet (internal/web/translation/en-US.json:705, :714). That is exactly the network the feature assumes filters and tampers with traffic. An on-path attacker on that ISP or Wi-Fi can swap the response for any script and get code execution on the admin's device. No hash, signature or pin checks what was fetched. When the proxy redirects port 80 to 443, urllib still follows the redirect for a GET, so the command appears to work while its first hop is plaintext (that redirect behaviour is inferred from the Python standard library, not reproduced). The trust boundary: anyone on the path between the device and the panel; no panel account is needed. This rests on reading the code, not on a reproduction.

MEDIUM — frontend/src/pages/inbounds/form/security/RealityFinderPanel.tsx:32-37, :124-127 with internal/web/service/reality_finder.go:133-140. The panel this PR adds receives the inbound's port, listen address and target, but not its nodeId, so an empty Server field resolves to the master's public IPv4. For an inbound whose "Deploy to" is a node (frontend/src/pages/inbounds/form/InboundFormModal.tsx:627), the script scans the master's /24 or AS and sends the filter-test SNIs to the master's port, not to the node where the inbound runs. If the master has nothing on that port, the report honestly says the filter test did not run. If the master does listen there (panel behind nginx on 443, or another inbound), the report marks targets "Open" on this ISP for a machine that is not serving this inbound. It also ranks "Same /24" and "Same AS" against the master, so Use writes a target close to the wrong server. The placeholder reads "This server's public IP" (en-US.json:707), which on a node inbound points the admin to the wrong host. The admin can only avoid this by typing the node's address by hand. This rests on reading the code.

MEDIUM — frontend/src/pages/inbounds/form/security/reality.tsx:403-410. The new onPickTarget replaces target and serverNames but leaves scanResult alone. The existing pick path refreshes it (frontend/src/pages/inbounds/form/useSecurityActions.ts:89-90). After a server-tab pick or a Scan of the old target, the form keeps showing that old result's alert under the newly picked target: green "feasible", the old "SNI used", TLS, ALPN, certificate and chain size (reality.tsx:166-214). With ML-DSA-65 enabled, the chain-size check (reality.tsx:76-83) then judges the old target's certChainBytes. The finder's script never measures chain size, so a picked target with a chain shorter than the 3500 bytes the repo expects (RealityTargetScannerModal.tsx:10) gets no warning, and may even get a green tick. I did not verify the upstream minimum: it lives in xtls/reality, which is not unpacked here. Typing a new target by hand already leaves the alert stale on base. This PR adds a pick that replaces the target outright, and that path does not refresh the alert. This rests on reading the code.

LOW — internal/web/controller/reality_finder.go:25-31. This PR adds requestBaseURL as a second way to work out the panel's public origin. It diverges from the two that exist. resolveHost (internal/web/controller/inbound.go:466-484) and the sub server's ResolveRequest (internal/sub/service.go:3378-3397) honour X-Forwarded-Host from a trusted proxy and accept forwarded headers only from one. requestBaseURL ignores X-Forwarded-Host and accepts X-Forwarded-Proto from any caller. Behind a proxy that rewrites Host to the upstream address, the command points at that upstream address, which the admin's device cannot reach. The share links built through resolveHost keep working in the same setup.

Coverage

  • Diff: 34 files, +3791 / −33, at 4472237. I read all hand-written code; the generated files and the 14 locale files I only checked for presence.
  • Controller and routing: reality_finder.go, api.go, web.go, against checkAPIAuth, CSRF, the body limit and getRemoteIp. The public routes are gated by a token. Finding: plaintext exec, plus the duplicated origin logic.
  • Service: token store, TTL, caps, eviction, cleaning, script substitution. The locking is correct; I found no injection path into the script.
  • Python script (900 lines): substitution header, network detection, scope/IP walk, filter test, router heuristic, report send. The REALITY forwarding assumption is unverified (xtls/reality not unpacked).
  • Frontend: RealityFinderPanel.tsx, the scanner modal tabs, the reality.tsx pick wiring, useSecurityActions.ts. Findings: node default server, stale scan result.
  • API chain: endpoints.ts, StructAllow, OpenAPI copy (identical), server.mdx regenerated. Complete.
  • Tests: reality_finder_test.go and reality-finder-panel.test.tsx read, not run; their assertions are exact.
  • Multi-node and upgrade: no DB or model change; jobs are in memory only; node-assigned inbounds are not handled (see MEDIUM).
  • CI: CI and Docs CI are action_required, waiting for maintainer approval. Nothing they cover (Go tests, lint, gen-check, i18n dead-key test, frontend tests, docs) is verified until a maintainer approves the run. prepare succeeded; review is this run.

…nd's own server

The one-day command fetched the finder script and exec'd whatever came back. On a
panel reached over plain HTTP that fetch crosses the users' ISP, the network the
feature treats as hostile, so anyone on the path could swap in their own code. The
script is now fixed when the command is made and the command carries its SHA-256:
a script that does not match is not run.

- For an inbound deployed to a node, an empty Server now means the node's address,
  not the master's public IP, so the scan and the SNI filter test hit the machine
  that serves the inbound.
- Picking a target from the finder clears the previous target's scan result, which
  otherwise kept its verdict and cert chain size on screen for the new target.
- The command's base URL honours X-Forwarded-Proto/-Host only from a trusted proxy,
  like resolveHost, and keeps the port the browser used.
@Plus98ir

Plus98ir commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 3875728 for all four findings.

  • HIGH (unchecked exec): the script is now fixed when the command is made, and the command carries its SHA-256. The one-liner fetches the script and runs it only if the hash matches; otherwise it prints that the script did not match and runs nothing. A swapped script over plain HTTP is refused. TestRealityFinderCommandRefusesASwappedScript runs the real command against a server that returns other code: it is red without the check and green with it.
  • MEDIUM (node inbound): the request carries the inbound's nodeId. With an empty Server, the scan now targets that node's address, not the master. A node on a private address gets the "enter it" error. Covered by TestRealityFinderScansTheNodeAnInboundIsDeployedTo and a panel test that checks the request body.
  • MEDIUM (stale scan): picking a target from the finder now goes through pickRealityTarget, which clears the old scan result. inbound-form-modal.test.tsx scans a target, picks one from the finder, and checks that the old result is gone.
  • LOW (base URL): requestBaseURL reads X-Forwarded-Proto/-Host only from a trusted proxy, like resolveHost, and keeps the port the browser used. Covered by TestRequestBaseURLTrustsForwardedHeadersOnlyFromAProxy.

make verify passes locally, except TestPublicIPStatusUsesRefreshedAddressWithoutBlocking. That test also fails on the base commit on Windows: gopsutil's Windows load goroutine stays inside the synctest bubble. It is unrelated to this PR.

# Conflicts:
#	frontend/src/test/inbound-form-modal.test.tsx
@Plus98ir

Plus98ir commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Merged current main; the conflict with #7003 is resolved and nothing in this PR's behaviour changed.

Verified locally on Windows: Go tests for internal/web/..., internal/sub/... and tools/..., golangci-lint, typecheck, oxfmt, oxlint, and the unit and component frontend suites pass. npm run gen leaves no diff and the docs OpenAPI copy matches.

sni_through caught OSError, and ssl.SSLError is one, so a dest refusing an
unknown name with a TLS alert was marked filtered. A dest that accepts only
its own name then marked every target filtered and made sni_router_check
report an SNI router that does not exist, dropping the whole filter result.

An alert proves the name crossed the network, so it now counts as through.
Reset, timeout, close and a non-TLS answer still count as cut.
@Plus98ir

Plus98ir commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

Merged current main to resolve the conflict with #7025. The uTLS field keeps main's NativeSelect, and nothing in this PR's behaviour changed.

Also pushed earlier and not yet mentioned here: cf9ca5b stops reading a dest's TLS alert as an ISP filter. A dest that refused unknown names used to mark every target filtered and made sni_router_check report an SNI router that does not exist.

Local: frontend tests, typecheck, lint, build, npm run gen and golangci-lint all pass. Go tests pass except TestPublicIPStatusUsesRefreshedAddressWithoutBlocking, which also fails on main on Windows.

Resolves the conflict with MHSanaei#7025: the uTLS field in the REALITY form keeps main's NativeSelect, and the form test keeps both sides' imports.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant