diff --git a/cmd/vocat/menu.go b/cmd/vocat/menu.go index 7807a20..281566f 100644 --- a/cmd/vocat/menu.go +++ b/cmd/vocat/menu.go @@ -523,6 +523,7 @@ func menuUpdate(m *menu, logger *slog.Logger) error { } fmt.Println(m.updateChecking()) if err := update.Run(logger, []string{"--repo", repo}); err != nil { + logger.Error("menu update failed", "error", err) return fmt.Errorf("%w: %v", errUpdateFailed, err) } return nil @@ -686,10 +687,11 @@ func (m *menu) errorPrefix(err error) string { } return "重启失败。" case errors.Is(err, errUpdateFailed): + detail := strings.TrimPrefix(err.Error(), errUpdateFailed.Error()+": ") if m.lang == "en" { - return "Update failed." + return "Update failed: " + detail } - return "更新失败。" + return "更新失败: " + detail case errors.Is(err, errMenuConfig): if m.lang == "en" { return "Failed to load configuration." diff --git a/internal/server/settings_api.go b/internal/server/settings_api.go index f5cf6e0..a4fab21 100644 --- a/internal/server/settings_api.go +++ b/internal/server/settings_api.go @@ -29,7 +29,7 @@ import ( ) var ( - errUnsafeDestination = errors.New("notification destination is not public") + errUnsafeDestination = errors.New("notification destination is not allowed") errProviderRejected = errors.New("notification provider rejected the test") telegramTokenPattern = regexp.MustCompile(`^[0-9]{5,20}:[A-Za-z0-9_-]{20,128}$`) ) @@ -459,7 +459,7 @@ func (s *Server) handleNotificationTest( w, http.StatusBadRequest, "unsafe_destination", - "notification destination must resolve only to public network addresses", + "notification destination resolved to an unusable or protected system address", ) case errors.Is(err, errProviderRejected): writeError( @@ -905,11 +905,12 @@ func restrictedHTTPClient( }, } if strings.TrimSpace(proxy) != "" { - parsed, err := validateOutboundURL(ctx, proxy, false) + parsed, err := validateNotificationProxyURL(ctx, proxy) if err != nil { return nil, fmt.Errorf("validate notification proxy: %w", err) } transport.Proxy = http.ProxyURL(parsed) + transport.DialContext = notificationProxyDialer(timeout) } return &http.Client{ Transport: transport, @@ -952,6 +953,17 @@ func validateOutboundURL( return parsed, nil } +func validateNotificationProxyURL(ctx context.Context, raw string) (*url.URL, error) { + parsed, err := parseOutboundURL(raw, false) + if err != nil { + return nil, err + } + if _, err := resolveNotificationProxyAddresses(ctx, parsed.Hostname()); err != nil { + return nil, err + } + return parsed, nil +} + func parseOutboundURL(raw string, requireHTTPS bool) (*url.URL, error) { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false { @@ -985,17 +997,42 @@ func restrictedDialer(timeout time.Duration) func( } } +func notificationProxyDialer(timeout time.Duration) func( + context.Context, + string, + string, +) (net.Conn, error) { + return func(ctx context.Context, network string, address string) (net.Conn, error) { + return dialNotification(ctx, network, address, timeout, true) + } +} + func dialRestricted( ctx context.Context, network string, address string, timeout time.Duration, +) (net.Conn, error) { + return dialNotification(ctx, network, address, timeout, false) +} + +func dialNotification( + ctx context.Context, + network string, + address string, + timeout time.Duration, + allowLocal bool, ) (net.Conn, error) { host, port, err := net.SplitHostPort(address) if err != nil { return nil, fmt.Errorf("parse outbound address: %w", err) } - addresses, err := resolvePublicAddresses(ctx, host) + var addresses []netip.Addr + if allowLocal { + addresses, err = resolveNotificationProxyAddresses(ctx, host) + } else { + addresses, err = resolvePublicAddresses(ctx, host) + } if err != nil { return nil, err } @@ -1071,54 +1108,68 @@ func dialRestricted( if len(failures) == 0 { return nil, ctx.Err() } - return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...)) + return nil, fmt.Errorf("dial notification destination: %w", errors.Join(failures...)) } -type notificationAllowedNetworksKey struct{} - func (s *Server) notificationDestinationContext(ctx context.Context) context.Context { if ctx == nil { - ctx = context.Background() + return context.Background() } - access := s.currentAccessConfig() - return context.WithValue(ctx, notificationAllowedNetworksKey{}, append([]netip.Prefix(nil), access.cidrs...)) + // Notification delivery is outbound administrator-configured traffic. It + // must not inherit the inbound Web access policy: DNS Fake-IP ranges, LAN + // gateways, and local proxies are valid notification paths. + return ctx } -func notificationAddressAllowed(ctx context.Context, address netip.Addr) bool { +func notificationAddressAllowed(_ context.Context, address netip.Addr) bool { address = address.Unmap() - // Even an administrator-provided exception must never turn a notification - // endpoint into a loopback or cloud-metadata request. Private/LAN and - // benchmark ranges may be explicitly allowed for local push gateways and - // DNS Fake-IP deployments, but these process-local destinations stay closed. - if !address.IsValid() || address.IsUnspecified() || address.IsLoopback() || - address.IsMulticast() || address.IsLinkLocalUnicast() || - address == netip.MustParseAddr("100.100.100.200") { + if !notificationTransportAddress(address) { return false } - if publicNotificationAddress(address) { - return true - } - prefixes, _ := ctx.Value(notificationAllowedNetworksKey{}).([]netip.Prefix) - for _, prefix := range prefixes { - if prefix.Contains(address) { + for _, fakeIP := range notificationFakeIPNetworks { + if fakeIP.Contains(address) { return true } } - return false + if !address.IsGlobalUnicast() { + return false + } + for _, blocked := range blockedNotificationDestinationNetworks { + if blocked.Contains(address) { + return false + } + } + return true +} + +func notificationProxyAddressAllowed(address netip.Addr) bool { + return notificationTransportAddress(address.Unmap()) +} + +func notificationTransportAddress(address netip.Addr) bool { + return address.IsValid() && !address.IsUnspecified() && !address.IsMulticast() && + !address.IsLinkLocalUnicast() && !address.IsLinkLocalMulticast() && + address != netip.MustParseAddr("255.255.255.255") && + address != netip.MustParseAddr("100.100.100.200") } func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, error) { + return resolveNotificationAddresses(ctx, host, false) +} + +func resolveNotificationProxyAddresses(ctx context.Context, host string) ([]netip.Addr, error) { + return resolveNotificationAddresses(ctx, host, true) +} + +func resolveNotificationAddresses(ctx context.Context, host string, allowLocal bool) ([]netip.Addr, error) { normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) - if normalized == "" || normalized == "localhost" || - strings.HasSuffix(normalized, ".localhost") || - normalized == "metadata" || - strings.HasSuffix(normalized, ".internal") || - strings.HasSuffix(normalized, ".local") { + if normalized == "" { return nil, fmt.Errorf("%w: blocked host name", errUnsafeDestination) } if literal, err := netip.ParseAddr(normalized); err == nil { literal = literal.Unmap() - if !notificationAddressAllowed(ctx, literal) { + if (!allowLocal && !notificationAddressAllowed(ctx, literal)) || + (allowLocal && !notificationProxyAddressAllowed(literal)) { return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal) } return []netip.Addr{literal}, nil @@ -1133,7 +1184,8 @@ func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, err result := make([]netip.Addr, 0, len(addresses)) for _, address := range addresses { address = address.Unmap() - if !notificationAddressAllowed(ctx, address) { + if (!allowLocal && !notificationAddressAllowed(ctx, address)) || + (allowLocal && !notificationProxyAddressAllowed(address)) { return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address) } result = append(result, address) @@ -1141,7 +1193,11 @@ func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, err return result, nil } -var blockedNotificationNetworks = []netip.Prefix{ +var notificationFakeIPNetworks = []netip.Prefix{ + netip.MustParsePrefix("198.18.0.0/15"), +} + +var blockedNotificationDestinationNetworks = []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("100.64.0.0/10"), @@ -1152,7 +1208,6 @@ var blockedNotificationNetworks = []netip.Prefix{ netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.88.99.0/24"), netip.MustParsePrefix("192.168.0.0/16"), - netip.MustParsePrefix("198.18.0.0/15"), netip.MustParsePrefix("198.51.100.0/24"), netip.MustParsePrefix("203.0.113.0/24"), netip.MustParsePrefix("224.0.0.0/4"), @@ -1167,19 +1222,6 @@ var blockedNotificationNetworks = []netip.Prefix{ netip.MustParsePrefix("ff00::/8"), } -func publicNotificationAddress(address netip.Addr) bool { - if !address.IsValid() || !address.IsGlobalUnicast() { - return false - } - address = address.Unmap() - for _, blocked := range blockedNotificationNetworks { - if blocked.Contains(address) { - return false - } - } - return true -} - func configString(config map[string]any, key string) string { value, _ := config[key].(string) return strings.TrimSpace(value) diff --git a/internal/server/settings_api_test.go b/internal/server/settings_api_test.go index 3837e15..61e91fd 100644 --- a/internal/server/settings_api_test.go +++ b/internal/server/settings_api_test.go @@ -369,6 +369,10 @@ func TestNotificationTestsBlockSSRFAndUnsupportedChannels(t *testing.T) { if recorder.Code != http.StatusBadRequest { t.Fatalf("Telegram metadata status = %d, body = %s", recorder.Code, recorder.Body) } + response = decodeSettingsResponse(t, recorder) + if response["error"].(map[string]any)["code"] != "unsafe_destination" { + t.Fatalf("Telegram metadata response = %#v", response) + } recorder = test.request( t, @@ -762,26 +766,28 @@ func TestTrafficAnalysisIsUnavailableOutsideDeveloperMode(t *testing.T) { } } -func TestNotificationDestinationAddressPolicy(t *testing.T) { +func TestNotificationDestinationAddressPolicyIsIndependentFromWebAccess(t *testing.T) { blocked := []string{ "0.0.0.0", "10.0.0.1", "100.100.100.200", "127.0.0.1", - "169.254.169.254", "172.16.0.1", "192.168.1.1", "198.18.0.1", - "::1", "fc00::1", "fe80::1", "2001:db8::1", + "169.254.169.254", "172.16.0.1", "192.168.1.1", "224.0.0.1", + "255.255.255.255", "::", "::1", "fc00::1", "fe80::1", "ff02::1", } for _, text := range blocked { address := netip.MustParseAddr(text) - if publicNotificationAddress(address) { - t.Errorf("%s was incorrectly accepted as public", text) + if notificationAddressAllowed(context.Background(), address) { + t.Errorf("%s was incorrectly accepted for notification transport", text) } } - for _, text := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} { + for _, text := range []string{ + "1.1.1.1", "198.18.0.1", "2606:4700:4700::1111", + } { address := netip.MustParseAddr(text) - if !publicNotificationAddress(address) { - t.Errorf("%s was incorrectly blocked", text) + if !notificationAddressAllowed(context.Background(), address) { + t.Errorf("%s was incorrectly blocked for notification transport", text) } } if _, err := resolvePublicAddresses(context.Background(), "localhost"); err == nil { - t.Fatal("localhost was not blocked") + t.Fatal("local notification destination was not blocked") } if _, err := resolvePublicAddresses( context.Background(), @@ -789,27 +795,53 @@ func TestNotificationDestinationAddressPolicy(t *testing.T) { ); err == nil { t.Fatal("metadata IP was not blocked") } - allowedContext := context.WithValue( - context.Background(), - notificationAllowedNetworksKey{}, - []netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")}, - ) - if addresses, err := resolvePublicAddresses(allowedContext, "198.18.0.1"); err != nil || len(addresses) != 1 { - t.Fatalf("explicit Fake-IP notification allowlist = %v, %v", addresses, err) + server := &Server{access: parsedAccessConfig{mode: "internal"}} + notificationContext := server.notificationDestinationContext(context.Background()) + if addresses, err := resolvePublicAddresses(notificationContext, "198.18.0.1"); err != nil || len(addresses) != 1 { + t.Fatalf("Fake-IP notification destination = %v, %v", addresses, err) } - if _, err := resolvePublicAddresses(allowedContext, "169.254.169.254"); err == nil { - t.Fatal("unlisted metadata IP was allowed") - } - wideAllowedContext := context.WithValue( - context.Background(), - notificationAllowedNetworksKey{}, - []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, - ) - for _, address := range []string{"127.0.0.1", "169.254.169.254", "100.100.100.200"} { - if _, err := resolvePublicAddresses(wideAllowedContext, address); err == nil { - t.Fatalf("non-overridable destination %s was allowed", address) +} + +func TestNotificationProxyAcceptsLocalAddressWithoutWebAccessAllowlist(t *testing.T) { + server := &Server{access: parsedAccessConfig{mode: "internal"}} + ctx := server.notificationDestinationContext(context.Background()) + for _, host := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "198.18.0.1", "::1"} { + if addresses, err := resolveNotificationProxyAddresses(ctx, host); err != nil || len(addresses) != 1 { + t.Errorf("local notification proxy %s = %v, %v", host, addresses, err) } } + if _, err := resolveNotificationProxyAddresses(ctx, "169.254.169.254"); err == nil { + t.Fatal("cloud metadata address was accepted as a notification proxy") + } +} + +func TestRestrictedNotificationClientConnectsThroughLocalProxy(t *testing.T) { + var hits atomic.Int32 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + hits.Add(1) + if request.URL.Host != "1.1.1.1" { + t.Errorf("proxy request host = %q", request.URL.Host) + } + w.WriteHeader(http.StatusNoContent) + })) + defer proxy.Close() + + client, err := restrictedHTTPClient(context.Background(), 2*time.Second, proxy.URL) + if err != nil { + t.Fatal(err) + } + request, err := http.NewRequest(http.MethodGet, "http://1.1.1.1/test", nil) + if err != nil { + t.Fatal(err) + } + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusNoContent || hits.Load() != 1 { + t.Fatalf("local proxy status = %d, hits = %d", response.StatusCode, hits.Load()) + } } func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) { diff --git a/internal/server/telegram_bot_test.go b/internal/server/telegram_bot_test.go index d6126af..42f9469 100644 --- a/internal/server/telegram_bot_test.go +++ b/internal/server/telegram_bot_test.go @@ -3,7 +3,6 @@ package server import ( "context" "errors" - "net/netip" "strings" "testing" "time" @@ -58,10 +57,8 @@ func TestTelegramAPIURLRejectsMalformedTemplates(t *testing.T) { } } -func TestTelegramPollingUsesExplicitFakeIPDestinationAllowlist(t *testing.T) { - bot := &telegramBot{server: &Server{access: parsedAccessConfig{ - cidrs: []netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")}, - }}} +func TestTelegramPollingAcceptsFakeIPWithoutWebAccessAllowlist(t *testing.T) { + bot := &telegramBot{server: &Server{access: parsedAccessConfig{mode: "internal"}}} ctx := bot.notificationDestinationContext(context.Background()) if _, err := validateTelegramAPIURL(ctx, "https://198.18.0.34", "123456:test-token", "getUpdates"); err != nil { t.Fatalf("explicitly allowed Telegram Fake-IP was rejected: %v", err) diff --git a/internal/update/asset_test.go b/internal/update/asset_test.go index bbcc3c3..652badd 100644 --- a/internal/update/asset_test.go +++ b/internal/update/asset_test.go @@ -1,6 +1,12 @@ package update import ( + "bytes" + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" "reflect" "testing" ) @@ -22,3 +28,26 @@ func TestAssetNamesFor(t *testing.T) { } } } + +func TestDownloadAssetWithProgressVerifiesPublishedSize(t *testing.T) { + payload := bytes.Repeat([]byte("vocat"), 4096) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(payload) + })) + defer server.Close() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + var destination bytes.Buffer + asset := &Asset{Name: "vocat-test", BrowserDownloadURL: server.URL, Size: int64(len(payload))} + if err := downloadAssetWithProgress(context.Background(), logger, asset, "", &destination); err != nil { + t.Fatal(err) + } + if !bytes.Equal(destination.Bytes(), payload) { + t.Fatal("downloaded asset content differs") + } + + asset.Size++ + if err := downloadAssetWithProgress(context.Background(), logger, asset, "", io.Discard); err == nil { + t.Fatal("download with a mismatched published size succeeded") + } +} diff --git a/internal/update/github.go b/internal/update/github.go index f03168d..6cc7014 100644 --- a/internal/update/github.go +++ b/internal/update/github.go @@ -2,11 +2,14 @@ package update import ( "context" + "crypto/tls" "encoding/json" "fmt" "io" + "net" "net/http" "strings" + "time" ) // Release mirrors the subset of the GitHub releases API response that the @@ -40,6 +43,23 @@ const ( DefaultRepository = "MengMengCode/VoCat" ) +var githubHTTPClient = &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: 15 * time.Second, + ResponseHeaderTimeout: 20 * time.Second, + ExpectContinueTimeout: time.Second, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, + }, +} + // LatestRelease fetches the newest published release for repo (form // "owner/name"). A non-empty token is sent as a Bearer header, which is // required for private repositories and lifts the unauthenticated rate limit. @@ -61,7 +81,7 @@ func LatestRelease(ctx context.Context, repo, token string) (*Release, error) { req.Header.Set("Authorization", "Bearer "+token) } - resp, err := http.DefaultClient.Do(req) + resp, err := githubHTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("update: fetch latest release: %w", err) } @@ -120,7 +140,7 @@ func downloadAsset(ctx context.Context, url, token string, dst io.Writer) error if token != "" { req.Header.Set("Authorization", "Bearer "+token) } - resp, err := http.DefaultClient.Do(req) + resp, err := githubHTTPClient.Do(req) if err != nil { return fmt.Errorf("update: download asset: %w", err) } diff --git a/internal/update/update.go b/internal/update/update.go index 7342299..745dedb 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -15,12 +15,14 @@ import ( "bytes" "context" "fmt" + "io" "log/slog" "os" "os/exec" "path/filepath" "runtime" "strings" + "sync/atomic" "time" "vocat/internal/buildinfo" @@ -149,7 +151,7 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release }() logger.Info("downloading binary", "asset", asset.Name, "size", asset.Size, "url", asset.BrowserDownloadURL) - if err := downloadAsset(ctx, asset.BrowserDownloadURL, opts.Token, tmp); err != nil { + if err := downloadAssetWithProgress(ctx, logger, asset, opts.Token, tmp); err != nil { cleanup() return err } @@ -206,6 +208,68 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release return nil } +type downloadProgressWriter struct { + destination io.Writer + downloaded atomic.Int64 +} + +func (writer *downloadProgressWriter) Write(data []byte) (int, error) { + written, err := writer.destination.Write(data) + writer.downloaded.Add(int64(written)) + return written, err +} + +func downloadAssetWithProgress( + ctx context.Context, + logger *slog.Logger, + asset *Asset, + token string, + destination io.Writer, +) error { + progress := &downloadProgressWriter{destination: destination} + done := make(chan struct{}) + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ctx.Done(): + return + case <-ticker.C: + downloaded := progress.downloaded.Load() + percent := float64(0) + if asset.Size > 0 { + percent = float64(downloaded) * 100 / float64(asset.Size) + } + logger.Info( + "download progress", + "asset", asset.Name, + "downloaded", downloaded, + "total", asset.Size, + "percent", fmt.Sprintf("%.1f", percent), + ) + } + } + }() + err := downloadAsset(ctx, asset.BrowserDownloadURL, token, progress) + close(done) + if err != nil { + return err + } + if asset.Size > 0 && progress.downloaded.Load() != asset.Size { + return fmt.Errorf( + "update: asset size mismatch for %s: downloaded %d bytes, expected %d", + asset.Name, + progress.downloaded.Load(), + asset.Size, + ) + } + logger.Info("download completed", "asset", asset.Name, "bytes", progress.downloaded.Load()) + return nil +} + // validateExecutable catches incompatible architectures and missing dynamic // loaders before the working installation is touched. A valid checksum alone // cannot detect those packaging errors. diff --git a/web/src/api.ts b/web/src/api.ts index e687b01..60edd08 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -83,7 +83,29 @@ export interface RequestOptions extends Omit { raw?: boolean; } -export async function api(path: string, options: RequestOptions = {}): Promise { +async function refreshCSRFToken(): Promise { + try { + const response = await fetch("/api/auth/session", { + method: "GET", + headers: { Accept: "application/json" }, + credentials: "include", + cache: "no-store", + }); + if (!response.ok) { + if (response.status === 401) notifyUnauthorized(); + return false; + } + const payload = await response.json() as { data?: { csrf_token?: string } }; + const token = payload?.data?.csrf_token; + if (!token) return false; + sessionStorage.setItem(CSRF_KEY, token); + return true; + } catch { + return false; + } +} + +async function requestAPI(path: string, options: RequestOptions, retryCSRF: boolean): Promise { const method = (options.method || "GET").toUpperCase(); const headers = new Headers(options.headers); const formBody = typeof FormData !== "undefined" && options.body instanceof FormData; @@ -116,7 +138,6 @@ export async function api(path: string, options: RequestOptions = {}): Promis : { message: await response.text() }; const normalized = camelize>(payload); if (!response.ok) { - if (response.status === 401) notifyUnauthorized(); const nested = normalized.error; const detail = nested && typeof nested === "object" ? { @@ -124,11 +145,26 @@ export async function api(path: string, options: RequestOptions = {}): Promis requestId: (normalized.requestId as string | undefined) || (nested as ApiErrorBody).requestId, } : normalized as ApiErrorBody; + if ( + retryCSRF && + isMutation(method) && + response.status === 403 && + detail.code === "invalid_csrf" + ) { + if (await refreshCSRFToken()) return requestAPI(path, options, false); + notifyUnauthorized(); + } else if (response.status === 401) { + notifyUnauthorized(); + } throw new ApiError(response.status, detail); } return (Object.prototype.hasOwnProperty.call(normalized, "data") ? normalized.data : normalized) as T; } +export async function api(path: string, options: RequestOptions = {}): Promise { + return requestAPI(path, options, true); +} + export async function login(username: string, password: string) { const result = await api("/auth/login", { method: "POST",