mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
fix(sower): guard the direct dial path against self-targeting too
DialSmart can reach directDial without passing DialProxyOnly (a direct rule match, or an access probe that marks the name reachable), and a direct dial back into this client's own proxy listener is a self-sustaining loop that never leaves the host. Guard both entries; the second check is a cache hit.
This commit is contained in:
+1
-1
@@ -138,7 +138,7 @@ For non-transport fallback traffic, it can route by TLS SNI to per-domain upstre
|
||||
- HTTP/HTTPS access probes are cached with an hour-long write TTL through `github.com/maypok86/otter/v2`, keeping repeated smart-routing checks bounded without hiding later reachability changes indefinitely.
|
||||
- Country routing treats `router.country.mmdb` as optional; an empty value disables GeoIP lookup and keeps CIDR-based matching active without startup warnings. A non-empty invalid MMDB path is a startup error.
|
||||
- DNS routing, transparent HTTP/HTTPS forwarding, and smart TCP routing are separate policies. DNS uses conservative explicit proxy rules only; transparent HTTP/HTTPS is proxy-only; SOCKS5 and explicit HTTP proxy traffic use smart routing.
|
||||
- A proxy dial must never re-enter this client's own listeners: `router.DialProxyOnly` refuses a target that resolves to one of the addresses the client serves proxy traffic on — the HTTP and HTTPS proxy listeners on every DNS listen address, plus SOCKS5 (`router.ErrSelfProxyTarget`; HTTP answers `403`, SOCKS5 `0x02` not-allowed). Without the guard one request to a name whose A record is the client's own serve address becomes a self-sustaining chain between the client and its relay (2026-10-12: 601 connections on r6c, and the relay host was OOM-killed). The target is resolved locally for the check because the relay resolves it remotely, and the answer is cached for a minute so a proxied dial does not pay a DNS round trip per connection; a resolution failure is not treated as evidence of self-targeting.
|
||||
- A proxy dial must never re-enter this client's own listeners: both dial entries (`router.DialSmart` for rule/probe-routed traffic and `router.DialProxyOnly` for the tunnel) refuse a target that resolves to one of the addresses the client serves proxy traffic on — the HTTP and HTTPS proxy listeners on every DNS listen address, plus SOCKS5 (`router.ErrSelfProxyTarget`; HTTP answers `403`, SOCKS5 `0x02` not-allowed). Both are guarded because `DialSmart` can reach `directDial` without passing `DialProxyOnly`, and either branch re-enters the proxy. Without the guard one request to a name whose A record is the client's own serve address becomes a self-sustaining chain between the client and its relay (2026-10-12: 601 connections on r6c, and the relay host was OOM-killed), or a local loop that never leaves the host when the target is classified direct. The target is resolved locally for the check because the relay resolves it remotely, and the answer is cached for a minute so a proxied dial does not pay a DNS round trip per connection; a resolution failure is not treated as evidence of self-targeting.
|
||||
- Fake site directory mode is loopback-only on port `80` to avoid exposing local static assets directly to the public internet.
|
||||
- `sowerd` prefers the user cache directory for ACME state, but falls back to `/var/cache/sower` so systemd services can start without `HOME`/`XDG_CACHE_HOME` or a config file.
|
||||
- `sowerd` fallback site routing is based only on exact TLS SNI matches; wildcard domains are not supported.
|
||||
|
||||
@@ -23,15 +23,19 @@ finding `f-4d7d5bd2-cee`):
|
||||
**代理拨号不得回到本机正在转发代理流量的监听器。** 只要目标(解析后)落在这组地址上,
|
||||
转发它必然再次进入同一个代理,于是链自持。
|
||||
|
||||
实现(单一收口点,`router.DialProxyOnly`,显式代理与自动路由两条路径都经过它):
|
||||
实现(两个拨号出口都守,因为 `handleHTTPConn` 走 `DialSmart` 而 `handleHTTPSConn` 走
|
||||
`DialProxyOnly`):
|
||||
|
||||
- `Router.SetSelfProxyAddrs(addrs...)` 记录本进程转发代理流量的监听地址:每个 DNS 监听
|
||||
地址上的 `:80`、`:443`,加上 SOCKS5 地址(`cmd/sower/selfProxyAddrs`)。admin-only 与
|
||||
DNS 监听器不参与:它们不转发,拨过去不可能成链。
|
||||
- `DialProxyOnly` 在拨号前调用 `selfTarget(domain, port)`:只有目标端口命中本机转发端口
|
||||
时才解析(否则直接跳过,零开销);目标是 IP 字面量时直接比较;否则本地解析(远端解析在
|
||||
relay 侧发生,所以这里只需要知道"那个地址是不是我"),结果按 `selfProxyLookupTTL`
|
||||
(1 分钟)缓存,避免每条被代理连接都付一次 DNS 往返。
|
||||
- `DialSmart` 入口与 `DialProxyOnly` 入口各检查一次 `selfTarget(domain, port)`:只有目标
|
||||
端口命中本机转发端口时才解析(否则直接跳过,零开销);目标是 IP 字面量时直接比较;否则
|
||||
本地解析(远端解析在 relay 侧发生,所以这里只需要知道"那个地址是不是我"),结果按
|
||||
`selfProxyLookupTTL`(1 分钟)缓存,避免每条被代理连接都付一次 DNS 往返。
|
||||
- 两个出口都必须守:`DialSmart` 的 direct 分支(DirectRule 命中,或 `isAccess` 探测把该名字
|
||||
判为可达)会直接 `directDial` 回到本机监听器,那时递归完全不出主机 —— 同样是自持的,只是
|
||||
不经过隧道。
|
||||
- 解析失败不视为自指证据:返回"无地址",拨号照常进行,由 relay 报自己的解析失败。
|
||||
- 拒绝错误是 `router.ErrSelfProxyTarget`:HTTP 返回 `403`,SOCKS5 回 `0x02`
|
||||
(connection not allowed by ruleset),HTTPS/SNI 路径关连接(该路径本来没有可写的响应)。
|
||||
|
||||
@@ -223,6 +223,14 @@ func (r *Router) DialSmart(network, domain string, port uint16) (net.Conn, error
|
||||
ctx := context.Background()
|
||||
addr := net.JoinHostPort(domain, strconv.FormatUint(uint64(port), 10))
|
||||
|
||||
// Guarded here as well as in DialProxyOnly: this path reaches directDial
|
||||
// without passing it (rule-based and probe-based direct decisions), and
|
||||
// dialing a listener this client itself forwards on re-enters the proxy
|
||||
// either way — locally, in that case, since no tunnel is involved.
|
||||
if ip, self := r.selfTarget(domain, port); self {
|
||||
return nil, selfProxyError(domain, port, ip)
|
||||
}
|
||||
|
||||
// 1. rule_based( block > direct > proxy )
|
||||
// 2. detect_based( CN IP || access site )
|
||||
// 3. fallback( proxy )
|
||||
|
||||
@@ -108,3 +108,29 @@ func TestDialProxyOnlyAllowsTargetThatFailsToResolve(t *testing.T) {
|
||||
t.Fatalf("dial after a resolution failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// DialSmart reaches directDial without passing DialProxyOnly, so the guard has
|
||||
// to sit on that path too: a name a direct rule matches, or one the access
|
||||
// probe marks reachable, would otherwise dial this client's own listener in a
|
||||
// loop that never leaves the host.
|
||||
func TestDialSmartRefusesDirectSelfTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newSelfTargetRouter(t, []string{"100.64.1.1:80"}, func(context.Context, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP("100.64.1.1")}, nil
|
||||
})
|
||||
r.DirectRule.Add("self.example.com")
|
||||
|
||||
if _, err := r.DialSmart("tcp", "self.example.com", 80); !errors.Is(err, ErrSelfProxyTarget) {
|
||||
t.Fatalf("direct-routed self target err = %v, want ErrSelfProxyTarget", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialSmartRefusesSelfTargetLiteral(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newSelfTargetRouter(t, []string{"100.64.1.1:443"}, nil)
|
||||
if _, err := r.DialSmart("tcp", "100.64.1.1", 443); !errors.Is(err, ErrSelfProxyTarget) {
|
||||
t.Fatalf("literal self target on the smart path err = %v, want ErrSelfProxyTarget", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user