fix: harden target recovery metadata safety

This commit is contained in:
proxyctl coordinator
2026-07-17 10:59:14 +08:00
parent a5360028ca
commit 613fe56540
7 changed files with 436 additions and 10 deletions
+64 -5
View File
@@ -26,10 +26,11 @@ import (
)
const (
proxySchema = "org.gnome.system.proxy"
httpSchema = proxySchema + ".http"
httpsSchema = proxySchema + ".https"
socksSchema = proxySchema + ".socks"
gnomeRecoveryVersion = 1
proxySchema = "org.gnome.system.proxy"
httpSchema = proxySchema + ".http"
httpsSchema = proxySchema + ".https"
socksSchema = proxySchema + ".socks"
)
var requiredSchemas = []string{proxySchema, httpSchema, httpsSchema, socksSchema}
@@ -226,6 +227,7 @@ type settings struct {
}
type recovery struct {
Version int `json:"version"`
Original settings `json:"original"`
LastFingerprint string `json:"lastFingerprint"`
}
@@ -421,6 +423,7 @@ func (a *Adapter) enable(ctx context.Context, active profile.Profile) error {
return fmt.Errorf("read GNOME recovery metadata: %w", err)
}
if !managed {
metadata.Version = gnomeRecoveryVersion
original, err := a.readSettings(ctx)
if err != nil {
return fmt.Errorf("snapshot original GNOME settings: %w", err)
@@ -480,13 +483,59 @@ func (a *Adapter) readRecovery() (recovery, bool, error) {
if err != nil {
return recovery{}, false, err
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return recovery{}, true, err
}
if fields == nil {
return recovery{}, true, errors.New("GNOME recovery metadata must be a JSON object")
}
for _, name := range []string{"version", "original", "lastFingerprint"} {
if _, present := fields[name]; !present {
return recovery{}, true, fmt.Errorf("GNOME recovery metadata is missing required field %q", name)
}
}
var originalFields map[string]json.RawMessage
if err := json.Unmarshal(fields["original"], &originalFields); err != nil || originalFields == nil {
return recovery{}, true, errors.New("GNOME recovery metadata original settings must be a JSON object")
}
for _, name := range []string{"mode", "autoConfigUrl", "ignoreHosts", "httpHost", "httpPort", "httpUseAuthentication", "httpAuthenticationUser", "httpAuthenticationPassword", "httpsHost", "httpsPort", "socksHost", "socksPort"} {
if _, present := originalFields[name]; !present {
return recovery{}, true, fmt.Errorf("GNOME recovery metadata original settings are missing required field %q", name)
}
}
var value recovery
if err := json.Unmarshal(data, &value); err != nil {
return recovery{}, true, err
}
if err := validateRecovery(value); err != nil {
return recovery{}, true, err
}
return value, true, nil
}
func validateRecovery(value recovery) error {
if value.Version != gnomeRecoveryVersion {
return fmt.Errorf("unsupported GNOME recovery metadata version %d", value.Version)
}
decoded, err := hex.DecodeString(value.LastFingerprint)
if err != nil || len(decoded) != sha256.Size {
return errors.New("GNOME recovery metadata has an invalid fingerprint")
}
if value.Original.Mode != "none" && value.Original.Mode != "manual" && value.Original.Mode != "auto" {
return errors.New("GNOME recovery metadata has an invalid original proxy mode")
}
if value.Original.IgnoreHosts == nil {
return errors.New("GNOME recovery metadata has an incomplete original ignore-hosts snapshot")
}
for _, port := range []int{value.Original.HTTPPort, value.Original.HTTPSPort, value.Original.SocksPort} {
if port < 0 || port > 65535 {
return errors.New("GNOME recovery metadata has an invalid original proxy port")
}
}
return nil
}
func (a *Adapter) command(args ...string) Command {
return Command{Name: a.configuration.CommandName, Args: append([]string(nil), args...), UID: a.configuration.UID, GID: a.configuration.GID, Env: map[string]string{
"HOME": a.configuration.HomeDir, "USER": a.configuration.Username, "LOGNAME": a.configuration.Username,
@@ -511,6 +560,13 @@ func (a *Adapter) set(ctx context.Context, schema, key, value string) error {
return nil
}
func (a *Adapter) setSensitiveString(ctx context.Context, schema, key, value string) error {
if _, err := a.run(ctx, "set", schema, key, value); err != nil {
return safeRedaction("set GNOME authentication password failed; sensitive argument and runner error suppressed", err)
}
return nil
}
func (a *Adapter) readSettings(ctx context.Context) (settings, error) {
var result settings
var err error
@@ -615,7 +671,7 @@ func (a *Adapter) writeSettings(ctx context.Context, value settings) error {
{proxySchema, "autoconfig-url", quote(value.AutoConfigURL)},
{proxySchema, "ignore-hosts", quoteStrings(value.IgnoreHosts)},
{httpSchema, "host", quote(value.HTTPHost)}, {httpSchema, "port", strconv.Itoa(value.HTTPPort)},
{httpSchema, "use-authentication", strconv.FormatBool(value.HTTPUseAuth)}, {httpSchema, "authentication-user", quote(value.HTTPAuthUser)}, {httpSchema, "authentication-password", quote(value.HTTPAuthPassword)},
{httpSchema, "use-authentication", strconv.FormatBool(value.HTTPUseAuth)}, {httpSchema, "authentication-user", quote(value.HTTPAuthUser)},
{httpsSchema, "host", quote(value.HTTPSHost)}, {httpsSchema, "port", strconv.Itoa(value.HTTPSPort)},
{socksSchema, "host", quote(value.SocksHost)}, {socksSchema, "port", strconv.Itoa(value.SocksPort)},
}
@@ -624,6 +680,9 @@ func (a *Adapter) writeSettings(ctx context.Context, value settings) error {
return err
}
}
if err := a.setSensitiveString(ctx, httpSchema, "authentication-password", quote(value.HTTPAuthPassword)); err != nil {
return err
}
return a.set(ctx, proxySchema, "mode", quote(value.Mode))
}
+85
View File
@@ -108,6 +108,91 @@ func active() *profile.Profile {
return &profile.Profile{Name: "office", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:8080", HTTPSURL: "http://secure.example:8443", AllURL: "socks5://socks.example:1080", Bypass: []string{"localhost", ".example.com", "10.0.0.0/8", "2001:db8::1"}}
}
func TestAuthenticationPasswordSetFailureSuppressesRunnerErrorAndProfileArgument(t *testing.T) {
const profilePassword = "GNOME-PROFILE-PASSWORD-07"
r, state := newRunner(), &memoryState{}
r.fail = func(c gnome.Command) error {
if len(c.Args) == 4 && c.Args[0] == "set" && c.Args[2] == "authentication-password" {
return fmt.Errorf("runner echoed argument %s: %w", c.Args[3], os.ErrPermission)
}
return nil
}
p := *active()
p.HTTPURL = "http://alice:" + profilePassword + "@proxy.example:8080"
result := adapter(r, state).SetDesired(context.Background(), true, &p)
if result.Err == nil || !errors.Is(result.Err, os.ErrPermission) {
t.Fatalf("set error classification = %#v", result)
}
if strings.Contains(result.Err.Error(), profilePassword) || strings.Contains(result.Err.Error(), "runner echoed") {
t.Fatalf("set exposed runner error or password argument: %v", result.Err)
}
if !strings.Contains(result.Err.Error(), "sensitive argument and runner error suppressed") {
t.Fatalf("set lacks fixed safe message: %v", result.Err)
}
}
func TestAuthenticationPasswordRestoreFailureSuppressesRunnerErrorAndSavedArgument(t *testing.T) {
const savedPassword = "GNOME-SAVED-ORIGINAL-PASSWORD-07"
r, state := newRunner(), &memoryState{}
r.values["org.gnome.system.proxy mode"] = "'auto'"
r.values["org.gnome.system.proxy.http authentication-password"] = "'" + savedPassword + "'"
a := adapter(r, state)
if result := a.SetDesired(context.Background(), true, active()); result.Err != nil {
t.Fatalf("enable = %#v", result)
}
r.fail = func(c gnome.Command) error {
if reflect.DeepEqual(c.Args[:min(len(c.Args), 3)], []string{"set", "org.gnome.system.proxy.http", "authentication-password"}) {
return fmt.Errorf("runner echoed argument %s: %w", c.Args[3], os.ErrPermission)
}
return nil
}
result := a.SetDesired(context.Background(), false, nil)
if result.Err == nil || !errors.Is(result.Err, os.ErrPermission) {
t.Fatalf("restore error classification = %#v", result)
}
if strings.Contains(result.Err.Error(), savedPassword) || strings.Contains(result.Err.Error(), "runner echoed") {
t.Fatalf("restore exposed runner error or password argument: %v", result.Err)
}
if !strings.Contains(result.Err.Error(), "sensitive argument and runner error suppressed") {
t.Fatalf("restore lacks fixed safe message: %v", result.Err)
}
}
func TestIncompleteRecoveryMetadataRejectsMutationAndPreservesGNOMESettings(t *testing.T) {
cases := map[string]string{
"empty object": `{}`,
"missing original": `{"version":1,"lastFingerprint":"` + strings.Repeat("a", 64) + `"}`,
"incomplete original": `{"version":1,"lastFingerprint":"` + strings.Repeat("a", 64) + `","original":{"mode":"none"}}`,
"invalid fingerprint": `{"version":1,"lastFingerprint":"short","original":{}}`,
}
for name, metadata := range cases {
t.Run(name, func(t *testing.T) {
r, state := newRunner(), &memoryState{data: []byte(metadata)}
before := make(map[string]string, len(r.values))
for key, value := range r.values {
before[key] = value
}
a := adapter(r, state)
if inspection := a.Inspect(context.Background(), nil); inspection.State != target.Drifted {
t.Fatalf("inspection = %#v", inspection)
}
callStart := len(r.calls)
result := a.SetDesired(context.Background(), false, nil)
if result.Err == nil || result.Inspection.State != target.Drifted {
t.Fatalf("unsafe disable = %#v", result)
}
if !reflect.DeepEqual(r.values, before) || string(state.data) != metadata {
t.Fatalf("configuration mutated: values=%#v metadata=%q", r.values, state.data)
}
for _, call := range r.calls[callStart:] {
if len(call.Args) > 0 && call.Args[0] == "set" {
t.Fatalf("unsafe metadata caused set: %#v", call)
}
}
})
}
}
func TestDiagnosticsSuppressSensitiveGSettingsOutputAndRunnerErrors(t *testing.T) {
const secret = "GNOME-DIAGNOSTIC-SECRET-7f3a"
for _, test := range []struct {