security fix
This commit is contained in:
+204
-30
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -15,6 +16,25 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const managedCredentialPrefix = "enc:v1:"
|
||||
|
||||
func sealManagedCredential(plain string) (string, error) {
|
||||
return sealCredential(managedCredentialPrefix, plain)
|
||||
}
|
||||
|
||||
func openManagedCredential(stored string) (string, error) {
|
||||
return openCredential(managedCredentialPrefix, stored)
|
||||
}
|
||||
|
||||
func managedServerHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ManagedServer struct {
|
||||
ID int
|
||||
Name string
|
||||
@@ -66,7 +86,43 @@ func (s *Store) EnsureManagedServersSchema(ctx context.Context) error {
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.migrateManagedServerCredentials(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) migrateManagedServerCredentials(ctx context.Context) error {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, admin_key FROM managed_servers WHERE admin_key <> '' AND admin_key NOT LIKE 'enc:v1:%'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type legacyCredential struct {
|
||||
id int
|
||||
key string
|
||||
}
|
||||
var legacy []legacyCredential
|
||||
for rows.Next() {
|
||||
var item legacyCredential
|
||||
if err := rows.Scan(&item.id, &item.key); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
legacy = append(legacy, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range legacy {
|
||||
sealed, err := sealManagedCredential(item.key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt managed server credential %d: %w", item.id, err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE managed_servers SET admin_key=$2 WHERE id=$1`, item.id, sealed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListManagedServers(ctx context.Context) ([]*ManagedServer, error) {
|
||||
@@ -83,6 +139,11 @@ func (s *Store) ListManagedServers(ctx context.Context) ([]*ManagedServer, error
|
||||
if err := rows.Scan(&ms.ID, &ms.Name, &ms.BaseURL, &ms.AdminUsername, &ms.AdminKey, &ms.EnableSSH, &ms.EnableXray, &ms.IsActive, &ms.CreatedAt, &ms.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plainKey, err := openManagedCredential(ms.AdminKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ms.AdminKey = plainKey
|
||||
out = append(out, ms)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -100,12 +161,17 @@ func (s *Store) GetManagedServer(ctx context.Context, id int) (*ManagedServer, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plainKey, err := openManagedCredential(ms.AdminKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ms.AdminKey = plainKey
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload) (*ManagedServer, error) {
|
||||
name := strings.TrimSpace(p.Name)
|
||||
baseURL := normalizeManagedServerBaseURL(p.BaseURL)
|
||||
baseURL, baseURLErr := validateManagedServerBaseURL(p.BaseURL)
|
||||
adminUsername := strings.TrimSpace(p.AdminUsername)
|
||||
if adminUsername == "" {
|
||||
adminUsername = "admin"
|
||||
@@ -113,8 +179,17 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("server name required")
|
||||
}
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("base url required")
|
||||
if len(name) > 120 || strings.IndexFunc(name, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||
return nil, fmt.Errorf("invalid server name")
|
||||
}
|
||||
if len(adminUsername) > 128 || strings.IndexFunc(adminUsername, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||
return nil, fmt.Errorf("invalid admin username")
|
||||
}
|
||||
if len(p.AdminKey) > 4096 || strings.IndexFunc(p.AdminKey, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||
return nil, fmt.Errorf("invalid admin credential")
|
||||
}
|
||||
if baseURLErr != nil {
|
||||
return nil, baseURLErr
|
||||
}
|
||||
if p.ID != "" && p.ID != "local" {
|
||||
id, err := strconv.Atoi(p.ID)
|
||||
@@ -127,10 +202,14 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
SET name=$2, base_url=$3, admin_username=$4, enable_ssh=$5, enable_xray=$6, is_active=$7, updated_at=NOW()
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
} else {
|
||||
sealedKey, sealErr := sealManagedCredential(p.AdminKey)
|
||||
if sealErr != nil {
|
||||
return nil, fmt.Errorf("encrypt admin credential: %w", sealErr)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE managed_servers
|
||||
SET name=$2, base_url=$3, admin_username=$4, admin_key=$5, enable_ssh=$6, enable_xray=$7, is_active=$8, updated_at=NOW()
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, p.AdminKey, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, sealedKey, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -140,8 +219,12 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
if strings.TrimSpace(p.AdminKey) == "" {
|
||||
return nil, fmt.Errorf("admin key/password required")
|
||||
}
|
||||
sealedKey, err := sealManagedCredential(p.AdminKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt admin credential: %w", err)
|
||||
}
|
||||
var id int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO managed_servers (name, base_url, admin_username, admin_key, enable_ssh, enable_xray, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT (base_url) DO UPDATE SET
|
||||
@@ -152,7 +235,7 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
enable_xray=EXCLUDED.enable_xray,
|
||||
is_active=EXCLUDED.is_active,
|
||||
updated_at=NOW()
|
||||
RETURNING id`, name, baseURL, adminUsername, p.AdminKey, p.EnableSSH, p.EnableXray, p.IsActive).Scan(&id)
|
||||
RETURNING id`, name, baseURL, adminUsername, sealedKey, p.EnableSSH, p.EnableXray, p.IsActive).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -193,21 +276,39 @@ func localManagedServerDTO() ManagedServerDTO {
|
||||
}
|
||||
|
||||
func normalizeManagedServerBaseURL(raw string) string {
|
||||
normalized, _ := validateManagedServerBaseURL(raw)
|
||||
return normalized
|
||||
}
|
||||
|
||||
func validateManagedServerBaseURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
return "", fmt.Errorf("base url required")
|
||||
}
|
||||
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
|
||||
lowerRaw := strings.ToLower(raw)
|
||||
if !strings.HasPrefix(lowerRaw, "http://") && !strings.HasPrefix(lowerRaw, "https://") {
|
||||
raw = "http://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return ""
|
||||
return "", fmt.Errorf("invalid base url")
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/")
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return "", fmt.Errorf("base url must use http or https")
|
||||
}
|
||||
if u.User != nil {
|
||||
return "", fmt.Errorf("base url must not contain credentials")
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return "", fmt.Errorf("base url must not contain a path")
|
||||
}
|
||||
if ip := net.ParseIP(u.Hostname()); ip != nil && (ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()) {
|
||||
return "", fmt.Errorf("base url uses a forbidden address")
|
||||
}
|
||||
u.Path = ""
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return strings.TrimRight(u.String(), "/")
|
||||
return strings.TrimRight(u.String(), "/"), nil
|
||||
}
|
||||
|
||||
func requestedServerID(r *http.Request) string {
|
||||
@@ -250,7 +351,7 @@ func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
client := managedServerHTTPClient(15 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -286,7 +387,7 @@ func proxyManagedServer(ctx context.Context, ms *ManagedServer, method, path str
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("X-Session-Token", token)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
client := managedServerHTTPClient(30 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
@@ -560,46 +661,119 @@ func handleManagedServerConfig(store *Store) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool {
|
||||
if owner == "" || username == "" {
|
||||
return false
|
||||
func remoteSSHUserOwner(ctx context.Context, ms *ManagedServer, username string) (owner string, exists bool, err error) {
|
||||
if username == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
return false
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote users returned HTTP %d", status)
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
return false
|
||||
return "", false, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if fmt.Sprint(row["username"]) == username && fmt.Sprint(row["owner_username"]) == owner {
|
||||
return true
|
||||
if fmt.Sprint(row["username"]) == username {
|
||||
return strings.TrimSpace(fmt.Sprint(row["owner_username"])), true, nil
|
||||
}
|
||||
}
|
||||
return false
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner string) bool {
|
||||
if owner == "" || uuid == "" {
|
||||
return false
|
||||
func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool {
|
||||
actualOwner, exists, err := remoteSSHUserOwner(ctx, ms, username)
|
||||
return err == nil && exists && actualOwner == owner
|
||||
}
|
||||
|
||||
func remoteXrayClientOwner(ctx context.Context, ms *ManagedServer, uuid string) (owner string, exists bool, err error) {
|
||||
if uuid == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
return false
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return false
|
||||
return "", false, err
|
||||
}
|
||||
for _, ib := range inbounds {
|
||||
clients, _ := ib["clients"].([]interface{})
|
||||
for _, c := range clients {
|
||||
m, _ := c.(map[string]interface{})
|
||||
if fmt.Sprint(m["id"]) == uuid && fmt.Sprint(m["owner_username"]) == owner {
|
||||
return true
|
||||
if fmt.Sprint(m["id"]) == uuid {
|
||||
return strings.TrimSpace(fmt.Sprint(m["owner_username"])), true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner string) bool {
|
||||
actualOwner, exists, err := remoteXrayClientOwner(ctx, ms, uuid)
|
||||
return err == nil && exists && actualOwner == owner
|
||||
}
|
||||
|
||||
func countOwnedQuotaAcrossManagedServers(ctx context.Context, store *Store, owner string) (int, error) {
|
||||
if store == nil || owner == "" {
|
||||
return 0, nil
|
||||
}
|
||||
total := countOwnedQuota(ctx, store, owner)
|
||||
servers, err := store.ListManagedServers(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, ms := range servers {
|
||||
if !ms.IsActive {
|
||||
continue
|
||||
}
|
||||
if ms.EnableSSH {
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote users returned HTTP %d", status)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
var users []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &users); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if strings.TrimSpace(fmt.Sprint(user["owner_username"])) == owner {
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
if ms.EnableXray {
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
clients, _ := inbound["clients"].([]interface{})
|
||||
for _, client := range clients {
|
||||
item, _ := client.(map[string]interface{})
|
||||
if strings.TrimSpace(fmt.Sprint(item["owner_username"])) == owner {
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user