mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-15 12:23:42 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfc6afb839 | ||
|
|
09c0ffc88b | ||
|
|
2d8552b670 |
@@ -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(os.Stdin)
|
||||
reader := bufio.NewReader(io.LimitReader(os.Stdin, 2049))
|
||||
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 password == "" {
|
||||
return errors.New("bootstrap password cannot be empty")
|
||||
if len(password) < 6 || len(password) > 1024 {
|
||||
return errors.New("bootstrap password must contain between 6 and 1024 characters")
|
||||
}
|
||||
adminUsername := strings.TrimSpace(*username)
|
||||
if len(adminUsername) < 1 || len(adminUsername) > 64 || strings.ContainsAny(adminUsername, "\r\n\t") {
|
||||
|
||||
+6
-6
@@ -198,7 +198,8 @@ func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
database, err := store.Open(ctx, cfg.DatabasePath)
|
||||
if err != nil {
|
||||
@@ -214,6 +215,7 @@ 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 {
|
||||
@@ -523,7 +525,6 @@ 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
|
||||
@@ -595,7 +596,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": {"新密码: ", "New password: "},
|
||||
"new_pw": {"新密码 (至少 6 位): ", "New password (min 6 chars): "},
|
||||
"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"},
|
||||
@@ -687,11 +688,10 @@ 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: " + detail
|
||||
return "Update failed."
|
||||
}
|
||||
return "更新失败: " + detail
|
||||
return "更新失败。"
|
||||
case errors.Is(err, errMenuConfig):
|
||||
if m.lang == "en" {
|
||||
return "Failed to load configuration."
|
||||
|
||||
+12
-48
@@ -1,7 +1,6 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
@@ -21,13 +20,8 @@ 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
|
||||
@@ -91,14 +85,14 @@ func (s *Service) EnsureAdmin(ctx context.Context, username string, password str
|
||||
current, err := s.store.CurrentAdmin(ctx)
|
||||
if err == nil &&
|
||||
current.Username == username &&
|
||||
comparePassword(current.PasswordHash, password) == nil {
|
||||
bcrypt.CompareHashAndPassword(current.PasswordHash, []byte(password)) == nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
return fmt.Errorf("auth: read configured admin: %w", err)
|
||||
}
|
||||
|
||||
passwordHash, err := hashPassword(password, s.bcryptCost)
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: hash admin password: %w", err)
|
||||
}
|
||||
@@ -133,8 +127,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 password == "" {
|
||||
return ErrEmptyPassword
|
||||
if len(password) < 6 || len(password) > 1024 {
|
||||
return errors.New("administrator password must contain between 6 and 1024 characters")
|
||||
}
|
||||
if err := s.EnsureAdmin(ctx, username, password); err != nil {
|
||||
return fmt.Errorf("auth: reset administrator credentials: %w", err)
|
||||
@@ -145,13 +139,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) {
|
||||
_ = comparePassword(s.dummyHash, password)
|
||||
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password))
|
||||
return Credentials{}, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return Credentials{}, fmt.Errorf("auth: find admin: %w", err)
|
||||
}
|
||||
if comparePassword(admin.PasswordHash, password) != nil {
|
||||
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(password)) != nil {
|
||||
return Credentials{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
@@ -290,24 +284,24 @@ func (s *Service) ChangePassword(
|
||||
currentPassword string,
|
||||
newPassword string,
|
||||
) error {
|
||||
if newPassword == "" {
|
||||
return ErrEmptyPassword
|
||||
if len(newPassword) < 6 || len(newPassword) > 1024 {
|
||||
return errors.New("new password must contain between 6 and 1024 characters")
|
||||
}
|
||||
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
_ = comparePassword(s.dummyHash, currentPassword)
|
||||
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(currentPassword))
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: find admin: %w", err)
|
||||
}
|
||||
if comparePassword(admin.PasswordHash, currentPassword) != nil {
|
||||
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(currentPassword)) != nil {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
if comparePassword(admin.PasswordHash, newPassword) == nil {
|
||||
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(newPassword)) == nil {
|
||||
return errors.New("new password must differ from the current password")
|
||||
}
|
||||
passwordHash, err := hashPassword(newPassword, s.bcryptCost)
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), s.bcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: hash new password: %w", err)
|
||||
}
|
||||
@@ -317,36 +311,6 @@ 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 {
|
||||
|
||||
@@ -3,7 +3,6 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -129,7 +128,7 @@ func TestResetAdminCredentialsValidatesInput(t *testing.T) {
|
||||
}{
|
||||
{name: "empty username", password: "replacement-password"},
|
||||
{name: "control whitespace", username: "bad\tname", password: "replacement-password"},
|
||||
{name: "empty password", username: "admin", password: ""},
|
||||
{name: "short password", username: "admin", password: "short"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := service.ResetAdminCredentials(context.Background(), test.username, test.password); err == nil {
|
||||
@@ -139,32 +138,6 @@ 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)
|
||||
|
||||
@@ -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 errors.Is(err, auth.ErrEmptyPassword):
|
||||
case strings.Contains(err.Error(), "between 6 and 1024"):
|
||||
writeError(w, http.StatusBadRequest, "weak_password", err.Error())
|
||||
case strings.Contains(err.Error(), "must differ"):
|
||||
writeError(w, http.StatusBadRequest, "password_reused", err.Error())
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
errUnsafeDestination = errors.New("notification destination is not allowed")
|
||||
errUnsafeDestination = errors.New("notification destination is not public")
|
||||
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 resolved to an unusable or protected system address",
|
||||
"notification destination must resolve only to public network addresses",
|
||||
)
|
||||
case errors.Is(err, errProviderRejected):
|
||||
writeError(
|
||||
@@ -905,12 +905,11 @@ func restrictedHTTPClient(
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(proxy) != "" {
|
||||
parsed, err := validateNotificationProxyURL(ctx, proxy)
|
||||
parsed, err := validateOutboundURL(ctx, proxy, false)
|
||||
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,
|
||||
@@ -953,17 +952,6 @@ 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 {
|
||||
@@ -997,42 +985,17 @@ 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)
|
||||
}
|
||||
var addresses []netip.Addr
|
||||
if allowLocal {
|
||||
addresses, err = resolveNotificationProxyAddresses(ctx, host)
|
||||
} else {
|
||||
addresses, err = resolvePublicAddresses(ctx, host)
|
||||
}
|
||||
addresses, err := resolvePublicAddresses(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1108,68 +1071,54 @@ func dialNotification(
|
||||
if len(failures) == 0 {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, fmt.Errorf("dial notification destination: %w", errors.Join(failures...))
|
||||
return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...))
|
||||
}
|
||||
|
||||
type notificationAllowedNetworksKey struct{}
|
||||
|
||||
func (s *Server) notificationDestinationContext(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
return context.Background()
|
||||
ctx = context.Background()
|
||||
}
|
||||
// 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
|
||||
access := s.currentAccessConfig()
|
||||
return context.WithValue(ctx, notificationAllowedNetworksKey{}, append([]netip.Prefix(nil), access.cidrs...))
|
||||
}
|
||||
|
||||
func notificationAddressAllowed(_ context.Context, address netip.Addr) bool {
|
||||
func notificationAddressAllowed(ctx context.Context, address netip.Addr) bool {
|
||||
address = address.Unmap()
|
||||
if !notificationTransportAddress(address) {
|
||||
// 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") {
|
||||
return false
|
||||
}
|
||||
for _, fakeIP := range notificationFakeIPNetworks {
|
||||
if fakeIP.Contains(address) {
|
||||
if publicNotificationAddress(address) {
|
||||
return true
|
||||
}
|
||||
prefixes, _ := ctx.Value(notificationAllowedNetworksKey{}).([]netip.Prefix)
|
||||
for _, prefix := range prefixes {
|
||||
if prefix.Contains(address) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
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")
|
||||
return false
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
if normalized == "" || normalized == "localhost" ||
|
||||
strings.HasSuffix(normalized, ".localhost") ||
|
||||
normalized == "metadata" ||
|
||||
strings.HasSuffix(normalized, ".internal") ||
|
||||
strings.HasSuffix(normalized, ".local") {
|
||||
return nil, fmt.Errorf("%w: blocked host name", errUnsafeDestination)
|
||||
}
|
||||
if literal, err := netip.ParseAddr(normalized); err == nil {
|
||||
literal = literal.Unmap()
|
||||
if (!allowLocal && !notificationAddressAllowed(ctx, literal)) ||
|
||||
(allowLocal && !notificationProxyAddressAllowed(literal)) {
|
||||
if !notificationAddressAllowed(ctx, literal) {
|
||||
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal)
|
||||
}
|
||||
return []netip.Addr{literal}, nil
|
||||
@@ -1184,8 +1133,7 @@ func resolveNotificationAddresses(ctx context.Context, host string, allowLocal b
|
||||
result := make([]netip.Addr, 0, len(addresses))
|
||||
for _, address := range addresses {
|
||||
address = address.Unmap()
|
||||
if (!allowLocal && !notificationAddressAllowed(ctx, address)) ||
|
||||
(allowLocal && !notificationProxyAddressAllowed(address)) {
|
||||
if !notificationAddressAllowed(ctx, address) {
|
||||
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address)
|
||||
}
|
||||
result = append(result, address)
|
||||
@@ -1193,11 +1141,7 @@ func resolveNotificationAddresses(ctx context.Context, host string, allowLocal b
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var notificationFakeIPNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
}
|
||||
|
||||
var blockedNotificationDestinationNetworks = []netip.Prefix{
|
||||
var blockedNotificationNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
@@ -1208,6 +1152,7 @@ var blockedNotificationDestinationNetworks = []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"),
|
||||
@@ -1222,6 +1167,19 @@ var blockedNotificationDestinationNetworks = []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)
|
||||
|
||||
@@ -369,10 +369,6 @@ 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,
|
||||
@@ -766,28 +762,26 @@ func TestTrafficAnalysisIsUnavailableOutsideDeveloperMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationDestinationAddressPolicyIsIndependentFromWebAccess(t *testing.T) {
|
||||
func TestNotificationDestinationAddressPolicy(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", "224.0.0.1",
|
||||
"255.255.255.255", "::", "::1", "fc00::1", "fe80::1", "ff02::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",
|
||||
}
|
||||
for _, text := range blocked {
|
||||
address := netip.MustParseAddr(text)
|
||||
if notificationAddressAllowed(context.Background(), address) {
|
||||
t.Errorf("%s was incorrectly accepted for notification transport", text)
|
||||
if publicNotificationAddress(address) {
|
||||
t.Errorf("%s was incorrectly accepted as public", text)
|
||||
}
|
||||
}
|
||||
for _, text := range []string{
|
||||
"1.1.1.1", "198.18.0.1", "2606:4700:4700::1111",
|
||||
} {
|
||||
for _, text := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} {
|
||||
address := netip.MustParseAddr(text)
|
||||
if !notificationAddressAllowed(context.Background(), address) {
|
||||
t.Errorf("%s was incorrectly blocked for notification transport", text)
|
||||
if !publicNotificationAddress(address) {
|
||||
t.Errorf("%s was incorrectly blocked", text)
|
||||
}
|
||||
}
|
||||
if _, err := resolvePublicAddresses(context.Background(), "localhost"); err == nil {
|
||||
t.Fatal("local notification destination was not blocked")
|
||||
t.Fatal("localhost was not blocked")
|
||||
}
|
||||
if _, err := resolvePublicAddresses(
|
||||
context.Background(),
|
||||
@@ -795,53 +789,27 @@ func TestNotificationDestinationAddressPolicyIsIndependentFromWebAccess(t *testi
|
||||
); err == nil {
|
||||
t.Fatal("metadata IP was not blocked")
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 := 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)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -57,8 +58,10 @@ func TestTelegramAPIURLRejectsMalformedTemplates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramPollingAcceptsFakeIPWithoutWebAccessAllowlist(t *testing.T) {
|
||||
bot := &telegramBot{server: &Server{access: parsedAccessConfig{mode: "internal"}}}
|
||||
func TestTelegramPollingUsesExplicitFakeIPDestinationAllowlist(t *testing.T) {
|
||||
bot := &telegramBot{server: &Server{access: parsedAccessConfig{
|
||||
cidrs: []netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")},
|
||||
}}}
|
||||
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)
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
@@ -28,26 +22,3 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,11 @@ 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
|
||||
@@ -43,23 +40,6 @@ 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.
|
||||
@@ -81,7 +61,7 @@ func LatestRelease(ctx context.Context, repo, token string) (*Release, error) {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := githubHTTPClient.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update: fetch latest release: %w", err)
|
||||
}
|
||||
@@ -140,7 +120,7 @@ func downloadAsset(ctx context.Context, url, token string, dst io.Writer) error
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := githubHTTPClient.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update: download asset: %w", err)
|
||||
}
|
||||
|
||||
@@ -15,14 +15,12 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"vocat/internal/buildinfo"
|
||||
@@ -151,7 +149,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 := downloadAssetWithProgress(ctx, logger, asset, opts.Token, tmp); err != nil {
|
||||
if err := downloadAsset(ctx, asset.BrowserDownloadURL, opts.Token, tmp); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
@@ -208,68 +206,6 @@ 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.
|
||||
|
||||
+2
-38
@@ -83,29 +83,7 @@ export interface RequestOptions extends Omit<RequestInit, "body"> {
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
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> {
|
||||
export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
const headers = new Headers(options.headers);
|
||||
const formBody = typeof FormData !== "undefined" && options.body instanceof FormData;
|
||||
@@ -138,6 +116,7 @@ async function requestAPI<T>(path: string, options: RequestOptions, retryCSRF: b
|
||||
: { 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"
|
||||
? {
|
||||
@@ -145,26 +124,11 @@ async function requestAPI<T>(path: string, options: RequestOptions, retryCSRF: b
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user