3 Commits
Author SHA1 Message Date
MengMengCode a624c454bc fix: support Fake-IP notifications and resilient updates 2026-08-14 22:59:46 +08:00
Meng MengandGitHub 1ef928db7d fix: remove CLI credential timeout and password complexity limits (#20) 2026-08-14 22:46:37 +08:00
Meng MengandGitHub 2dceb74e61 Feat/admin credentials pr limit (#19)
* feat: reset admin credentials without requiring current password and update related prompts

* feat: update password requirements to a minimum of 6 characters for admin credentials
2026-08-14 21:52:53 +08:00
13 changed files with 391 additions and 107 deletions
+1
View File
@@ -10,6 +10,7 @@
*.dll
*.so
*.dylib
/fix
# ---- Cookie / secret files (NEVER commit) ----
vc.jar
+3 -3
View File
@@ -23,14 +23,14 @@ func runBootstrapAdmin(args []string) error {
if err := flags.Parse(args); err != nil || flags.NArg() != 0 {
return errors.New("usage: vocat bootstrap-admin [--database path] [--username name]")
}
reader := bufio.NewReader(io.LimitReader(os.Stdin, 2049))
reader := bufio.NewReader(os.Stdin)
password, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return fmt.Errorf("read password: %w", err)
}
password = strings.TrimSuffix(strings.TrimSuffix(password, "\n"), "\r")
if len(password) < 12 || len(password) > 1024 {
return errors.New("bootstrap password must contain between 12 and 1024 characters")
if password == "" {
return errors.New("bootstrap password cannot be empty")
}
adminUsername := strings.TrimSpace(*username)
if len(adminUsername) < 1 || len(adminUsername) > 64 || strings.ContainsAny(adminUsername, "\r\n\t") {
+6 -6
View File
@@ -198,8 +198,7 @@ func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error {
if err != nil {
return fmt.Errorf("%w: %v", errMenuConfig, err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ctx := context.Background()
database, err := store.Open(ctx, cfg.DatabasePath)
if err != nil {
@@ -215,7 +214,6 @@ func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error {
if err != nil {
return fmt.Errorf("%w: %v", errMenuStore, err)
}
fmt.Print(m.newUsername(admin.Username))
username, err := reader.ReadString('\n')
if err != nil {
@@ -525,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
@@ -596,7 +595,7 @@ func (m *menu) msg(key string) string {
"prompt": {"请选择: ", "Select: "},
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
"new_username": {"新用户名(直接回车保留 %s: ", "New username (Enter to keep %s): "},
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
"new_pw": {"新密码: ", "New password: "},
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
"pw_changed": {"管理员账号密码已修改,现有 Web 会话已退出。", "Administrator credentials changed; existing Web sessions were signed out."},
"current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"},
@@ -688,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."
+48 -12
View File
@@ -1,6 +1,7 @@
package auth
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
@@ -20,8 +21,13 @@ var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUnauthorized = errors.New("unauthorized")
ErrInvalidCSRF = errors.New("invalid csrf token")
ErrEmptyPassword = errors.New("password cannot be empty")
)
const bcryptPasswordLimit = 72
var longPasswordHashPrefix = []byte("$vocat-sha256$")
type Options struct {
SessionTTL time.Duration
BcryptCost int
@@ -85,14 +91,14 @@ func (s *Service) EnsureAdmin(ctx context.Context, username string, password str
current, err := s.store.CurrentAdmin(ctx)
if err == nil &&
current.Username == username &&
bcrypt.CompareHashAndPassword(current.PasswordHash, []byte(password)) == nil {
comparePassword(current.PasswordHash, password) == nil {
return nil
}
if err != nil && !errors.Is(err, store.ErrNotFound) {
return fmt.Errorf("auth: read configured admin: %w", err)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost)
passwordHash, err := hashPassword(password, s.bcryptCost)
if err != nil {
return fmt.Errorf("auth: hash admin password: %w", err)
}
@@ -127,8 +133,8 @@ func (s *Service) ResetAdminCredentials(ctx context.Context, username string, pa
if len(username) < 1 || len(username) > 64 || strings.ContainsAny(username, "\r\n\t") {
return errors.New("administrator username must contain between 1 and 64 characters without control whitespace")
}
if len(password) < 12 || len(password) > 1024 {
return errors.New("administrator password must contain between 12 and 1024 characters")
if password == "" {
return ErrEmptyPassword
}
if err := s.EnsureAdmin(ctx, username, password); err != nil {
return fmt.Errorf("auth: reset administrator credentials: %w", err)
@@ -139,13 +145,13 @@ func (s *Service) ResetAdminCredentials(ctx context.Context, username string, pa
func (s *Service) Login(ctx context.Context, username string, password string) (Credentials, error) {
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
if errors.Is(err, store.ErrNotFound) {
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password))
_ = comparePassword(s.dummyHash, password)
return Credentials{}, ErrInvalidCredentials
}
if err != nil {
return Credentials{}, fmt.Errorf("auth: find admin: %w", err)
}
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(password)) != nil {
if comparePassword(admin.PasswordHash, password) != nil {
return Credentials{}, ErrInvalidCredentials
}
@@ -284,24 +290,24 @@ func (s *Service) ChangePassword(
currentPassword string,
newPassword string,
) error {
if len(newPassword) < 12 || len(newPassword) > 1024 {
return errors.New("new password must contain between 12 and 1024 characters")
if newPassword == "" {
return ErrEmptyPassword
}
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
if errors.Is(err, store.ErrNotFound) {
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(currentPassword))
_ = comparePassword(s.dummyHash, currentPassword)
return ErrInvalidCredentials
}
if err != nil {
return fmt.Errorf("auth: find admin: %w", err)
}
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(currentPassword)) != nil {
if comparePassword(admin.PasswordHash, currentPassword) != nil {
return ErrInvalidCredentials
}
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(newPassword)) == nil {
if comparePassword(admin.PasswordHash, newPassword) == nil {
return errors.New("new password must differ from the current password")
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), s.bcryptCost)
passwordHash, err := hashPassword(newPassword, s.bcryptCost)
if err != nil {
return fmt.Errorf("auth: hash new password: %w", err)
}
@@ -311,6 +317,36 @@ func (s *Service) ChangePassword(
return nil
}
// hashPassword keeps ordinary bcrypt hashes compatible with existing
// installations. bcrypt rejects inputs longer than 72 bytes, so only longer
// passwords use a tagged SHA-256 pre-hash before bcrypt.
func hashPassword(password string, cost int) ([]byte, error) {
material := []byte(password)
longPassword := len(material) > bcryptPasswordLimit
if longPassword {
digest := sha256.Sum256(material)
material = digest[:]
}
passwordHash, err := bcrypt.GenerateFromPassword(material, cost)
if err != nil {
return nil, err
}
if !longPassword {
return passwordHash, nil
}
return append(append([]byte(nil), longPasswordHashPrefix...), passwordHash...), nil
}
func comparePassword(passwordHash []byte, password string) error {
material := []byte(password)
if bytes.HasPrefix(passwordHash, longPasswordHashPrefix) {
digest := sha256.Sum256(material)
material = digest[:]
passwordHash = passwordHash[len(longPasswordHashPrefix):]
}
return bcrypt.CompareHashAndPassword(passwordHash, material)
}
func randomToken() (string, error) {
buffer := make([]byte, 32)
if _, err := rand.Read(buffer); err != nil {
+28 -1
View File
@@ -3,6 +3,7 @@ package auth
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -128,7 +129,7 @@ func TestResetAdminCredentialsValidatesInput(t *testing.T) {
}{
{name: "empty username", password: "replacement-password"},
{name: "control whitespace", username: "bad\tname", password: "replacement-password"},
{name: "short password", username: "admin", password: "short"},
{name: "empty password", username: "admin", password: ""},
} {
t.Run(test.name, func(t *testing.T) {
if err := service.ResetAdminCredentials(context.Background(), test.username, test.password); err == nil {
@@ -138,6 +139,32 @@ func TestResetAdminCredentialsValidatesInput(t *testing.T) {
}
}
func TestResetAdminCredentialsAcceptsPasswordsWithoutComplexityRules(t *testing.T) {
ctx := context.Background()
for _, password := range []string{"1", strings.Repeat("x", 256)} {
service := newTestService(t)
if err := service.ResetAdminCredentials(ctx, "admin", password); err != nil {
t.Fatalf("ResetAdminCredentials(%d-byte password) error = %v", len(password), err)
}
if _, err := service.Login(ctx, "admin", password); err != nil {
t.Fatalf("Login(%d-byte password) error = %v", len(password), err)
}
}
}
func TestChangePasswordAcceptsPasswordsWithoutComplexityRules(t *testing.T) {
ctx := context.Background()
for _, password := range []string{"1", strings.Repeat("long-password-", 32)} {
service := newTestService(t)
if err := service.ChangePassword(ctx, "admin", "correct-password", password); err != nil {
t.Fatalf("ChangePassword(%d-byte password) error = %v", len(password), err)
}
if _, err := service.Login(ctx, "admin", password); err != nil {
t.Fatalf("Login(%d-byte password) error = %v", len(password), err)
}
}
}
func TestEnsureAdminIfMissingDoesNotOverwriteChangedPassword(t *testing.T) {
ctx := context.Background()
service := newTestService(t)
+1 -1
View File
@@ -506,7 +506,7 @@ func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
switch {
case errors.Is(err, auth.ErrInvalidCredentials):
writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password is incorrect")
case strings.Contains(err.Error(), "between 12 and 1024"):
case errors.Is(err, auth.ErrEmptyPassword):
writeError(w, http.StatusBadRequest, "weak_password", err.Error())
case strings.Contains(err.Error(), "must differ"):
writeError(w, http.StatusBadRequest, "password_reused", err.Error())
+89 -47
View File
@@ -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)
+59 -27
View File
@@ -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) {
+2 -5
View File
@@ -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)
+29
View File
@@ -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")
}
}
+22 -2
View File
@@ -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)
}
+65 -1
View File
@@ -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.
+38 -2
View File
@@ -83,7 +83,29 @@ export interface RequestOptions extends Omit<RequestInit, "body"> {
raw?: boolean;
}
export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
async function refreshCSRFToken(): Promise<boolean> {
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<T>(path: string, options: RequestOptions, retryCSRF: boolean): Promise<T> {
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<T>(path: string, options: RequestOptions = {}): Promis
: { message: await response.text() };
const normalized = camelize<Record<string, unknown>>(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<T>(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<T>(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<T>(path: string, options: RequestOptions = {}): Promise<T> {
return requestAPI<T>(path, options, true);
}
export async function login(username: string, password: string) {
const result = await api<LoginResponse & { user?: { username?: string } }>("/auth/login", {
method: "POST",