security fix

This commit is contained in:
2026-07-13 00:57:28 -03:00
parent ba5b581aaf
commit 9001b47204
24 changed files with 1706 additions and 677 deletions
+121 -6
View File
@@ -1328,6 +1328,16 @@ func NewStore(dsn string) (*Store, error) {
return store, nil
}
const sshPasswordPrefix = "enc:v1:ssh:"
func sealSSHPassword(password string) (string, error) {
return sealCredential(sshPasswordPrefix, password)
}
func openSSHPassword(password string) (string, error) {
return openCredential(sshPasswordPrefix, password)
}
func (s *Store) EnsureUsersSchema(ctx context.Context) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS ssh_users (
@@ -1355,6 +1365,39 @@ func (s *Store) EnsureUsersSchema(ctx context.Context) error {
return err
}
}
return s.migrateSSHPasswords(ctx)
}
func (s *Store) migrateSSHPasswords(ctx context.Context) error {
rows, err := s.db.QueryContext(ctx, `SELECT username, password FROM ssh_users WHERE password <> '' AND password NOT LIKE 'enc:v1:ssh:%'`)
if err != nil {
return err
}
type legacyPassword struct {
username string
password string
}
var legacy []legacyPassword
for rows.Next() {
var item legacyPassword
if err := rows.Scan(&item.username, &item.password); err != nil {
rows.Close()
return err
}
legacy = append(legacy, item)
}
if err := rows.Close(); err != nil {
return err
}
for _, item := range legacy {
sealed, err := sealSSHPassword(item.password)
if err != nil {
return fmt.Errorf("encrypt SSH password for %s: %w", item.username, err)
}
if _, err := s.db.ExecContext(ctx, `UPDATE ssh_users SET password=$2 WHERE username=$1`, item.username, sealed); err != nil {
return err
}
}
return nil
}
@@ -1391,6 +1434,10 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
&totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &ownerUsername); err != nil {
return nil, err
}
password, err = openSSHPassword(password)
if err != nil {
return nil, fmt.Errorf("decrypt SSH password for %s: %w", username, err)
}
cfg := UserConfig{
Username: username,
@@ -1426,7 +1473,11 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
// UpsertUser creates or updates a row in ssh_users.
func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error {
_, err := s.db.ExecContext(ctx, `
storedPassword, err := sealSSHPassword(u.Password)
if err != nil {
return fmt.Errorf("encrypt SSH password: %w", err)
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO ssh_users (
username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down,
totp_secret, totp_period, totp_window, totp_digits, allow_static_password, owner_username
@@ -1444,7 +1495,7 @@ func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error {
totp_digits = EXCLUDED.totp_digits,
allow_static_password = EXCLUDED.allow_static_password`,
// owner_username is intentionally excluded from UPDATE — ownership is set at creation only.
u.Username, u.Password, u.MaxConnections, u.ExpiresAt, u.LimitMbpsUp, u.LimitMbpsDown,
u.Username, storedPassword, u.MaxConnections, u.ExpiresAt, u.LimitMbpsUp, u.LimitMbpsDown,
u.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.OwnerUsername)
return err
}
@@ -1634,7 +1685,16 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
go func() {
log.Printf("Admin HTTP (panel + API) listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
server := &http.Server{
Addr: addr,
Handler: securePanelHandler(mux),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 2 * time.Minute,
IdleTimeout: 90 * time.Second,
MaxHeaderBytes: 1 << 20,
}
if err := server.ListenAndServe(); err != nil {
log.Printf("admin http error: %v", err)
}
}()
@@ -1756,6 +1816,27 @@ func handleCreateUser(store *Store) http.HandlerFunc {
return
}
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller {
currentOwner, exists, ownerErr := remoteSSHUserOwner(ctx, ms, p.Username)
if ownerErr != nil {
http.Error(w, "could not verify remote ownership", http.StatusBadGateway)
return
}
if exists && currentOwner != sess.Username {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if !exists {
owner, ok := adminUsers.get(sess.Username)
used, quotaErr := countOwnedQuotaAcrossManagedServers(ctx, store, sess.Username)
if quotaErr != nil {
http.Error(w, "could not verify reseller quota", http.StatusBadGateway)
return
}
if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers {
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
return
}
}
p.OwnerUsername = sess.Username
}
p.ServerID = ""
@@ -1769,6 +1850,20 @@ func handleCreateUser(store *Store) http.HandlerFunc {
return
}
sess := sessionFromCtx(ctx)
if sess != nil && sess.Role == RoleReseller {
var existingOwner string
err := store.db.QueryRowContext(ctx, `SELECT owner_username FROM ssh_users WHERE username=$1`, p.Username).Scan(&existingOwner)
if err != nil && err != sql.ErrNoRows {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
if err == nil && existingOwner != sess.Username {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
}
// Decide what password to use:
// - if payload has non-empty password -> use it
// - else try to read existing password from DB
@@ -1802,7 +1897,6 @@ func handleCreateUser(store *Store) http.HandlerFunc {
}
// Determine owner and enforce reseller quota
sess := sessionFromCtx(ctx)
ownerUsername := ""
if sess != nil && sess.Role == RoleReseller {
ownerUsername = sess.Username
@@ -1813,7 +1907,12 @@ func handleCreateUser(store *Store) http.HandlerFunc {
).Scan(&existsInDB)
if !existsInDB {
owner, ok := adminUsers.get(sess.Username)
if ok && owner.MaxUsers > 0 && countOwnedQuota(ctx, store, sess.Username) >= owner.MaxUsers {
used, quotaErr := countOwnedQuotaAcrossManagedServers(ctx, store, sess.Username)
if quotaErr != nil {
http.Error(w, "could not verify reseller quota", http.StatusBadGateway)
return
}
if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers {
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
return
}
@@ -2218,7 +2317,7 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi
return
}
target := fmt.Sprintf("%s:%d", req.Host, req.Port)
target := net.JoinHostPort(req.Host, strconv.FormatUint(uint64(req.Port), 10))
log.Printf("direct-tcpip: user=%s connecting to %s from %s:%d",
u.Cfg.Username, target, req.OriginAddr, req.OriginPort)
@@ -2841,7 +2940,23 @@ func main() {
configPath := flag.String("config", "", "path to JSON config file (default: ./config.json if present, otherwise /opt/sshpanel/config.json)")
quietFlag := flag.Bool("quiet", false, "override config and disable logs")
userCountFlag := flag.Bool("usercount", false, "show per-user connection counters (single line)")
hashAdminPasswordStdin := flag.Bool("hash-admin-password-stdin", false, "read an admin password from stdin and print a bcrypt hash")
flag.Parse()
if *hashAdminPasswordStdin {
password, readErr := io.ReadAll(io.LimitReader(os.Stdin, 1025))
if readErr != nil {
log.Fatalf("read admin password: %v", readErr)
}
if err := validateAdminPassword(string(password)); err != nil {
log.Fatal(err)
}
hash, hashErr := hashAdminPassword(string(password))
if hashErr != nil {
log.Fatal(hashErr)
}
fmt.Println(hash)
return
}
resolvedConfigPath := resolveMainConfigPath(*configPath)
cfg, userMap, err := loadConfig(resolvedConfigPath)