Skip to content

Commit 29f6f35

Browse files
committed
fix: preserve unknown fields in policy.json during read-modify-write cycles
The Policy struct only defined fields it explicitly used, causing any other fields in policy.json to be silently dropped when AddExtension performed a read-modify-write cycle. This broke the DefaultGeolocationSetting added in #136. Changes: - Add custom UnmarshalJSON/MarshalJSON to preserve unknown JSON fields - Simplify Policy struct to only include fields we programmatically modify (ExtensionInstallForcelist, ExtensionSettings) - All other Chrome policy settings are now automatically preserved - Add comprehensive tests for the preservation behavior This allows policy.json to contain any Chrome policy setting without requiring Go struct updates, as long as we don't need to modify it in code. Fixes issue introduced by #136
1 parent ba0852c commit 29f6f35

File tree

2 files changed

+254
-28
lines changed

2 files changed

+254
-28
lines changed

server/lib/policy/policy.go

Lines changed: 84 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,30 +12,94 @@ import (
1212
)
1313

1414
const PolicyPath = "/etc/chromium/policies/managed/policy.json"
15-
const DefaultSearchProviderName = "DuckDuckGo"
16-
const DefaultSearchProviderSearchURL = "https://duckduckgo.com/?q={searchTerms}"
17-
const DefaultSearchProviderSuggestURL = "https://duckduckgo.com/ac/?q={searchTerms}"
18-
const NewTabPageLocation = "https://start.duckduckgo.com/"
1915

2016
// Chrome extension IDs are 32 lowercase a-p characters
2117
var extensionIDRegex = regexp.MustCompile(`^[a-p]{32}$`)
2218
var extensionPathRegex = regexp.MustCompile(`/extensions/[^/]+/`)
2319

24-
// Policy represents the Chrome enterprise policy structure
20+
// Policy represents the Chrome enterprise policy structure.
21+
// Only fields that are programmatically modified are defined here.
22+
// All other fields (like DefaultGeolocationSetting, PasswordManagerEnabled, etc.)
23+
// are preserved through the unknownFields mechanism during read-modify-write cycles.
2524
type Policy struct {
2625
mu sync.Mutex
2726

28-
PasswordManagerEnabled bool `json:"PasswordManagerEnabled"`
29-
AutofillCreditCardEnabled bool `json:"AutofillCreditCardEnabled"`
30-
TranslateEnabled bool `json:"TranslateEnabled"`
31-
DefaultNotificationsSetting int `json:"DefaultNotificationsSetting"`
32-
DefaultSearchProviderEnabled bool `json:"DefaultSearchProviderEnabled"`
33-
DefaultSearchProviderName string `json:"DefaultSearchProviderName"`
34-
DefaultSearchProviderSearchURL string `json:"DefaultSearchProviderSearchURL"`
35-
DefaultSearchProviderSuggestURL string `json:"DefaultSearchProviderSuggestURL"`
36-
NewTabPageLocation string `json:"NewTabPageLocation"`
37-
ExtensionInstallForcelist []string `json:"ExtensionInstallForcelist,omitempty"`
38-
ExtensionSettings map[string]ExtensionSetting `json:"ExtensionSettings"`
27+
// ExtensionInstallForcelist is modified when adding force-installed extensions
28+
ExtensionInstallForcelist []string `json:"ExtensionInstallForcelist,omitempty"`
29+
// ExtensionSettings is modified when adding/configuring extensions
30+
ExtensionSettings map[string]ExtensionSetting `json:"ExtensionSettings"`
31+
32+
// unknownFields preserves all JSON fields not explicitly defined in this struct.
33+
// This allows policy.json to contain any Chrome policy settings without
34+
// requiring updates to this Go struct.
35+
unknownFields map[string]json.RawMessage
36+
}
37+
38+
// knownPolicyFields lists all JSON field names that have corresponding struct fields.
39+
// All other fields are automatically preserved in unknownFields.
40+
var knownPolicyFields = map[string]bool{
41+
"ExtensionInstallForcelist": true,
42+
"ExtensionSettings": true,
43+
}
44+
45+
// UnmarshalJSON implements custom JSON unmarshaling that preserves unknown fields.
46+
func (p *Policy) UnmarshalJSON(data []byte) error {
47+
// First, unmarshal into a map to capture all fields
48+
var allFields map[string]json.RawMessage
49+
if err := json.Unmarshal(data, &allFields); err != nil {
50+
return err
51+
}
52+
53+
// Use an alias type to avoid infinite recursion when unmarshaling known fields
54+
type PolicyAlias Policy
55+
var alias PolicyAlias
56+
if err := json.Unmarshal(data, &alias); err != nil {
57+
return err
58+
}
59+
60+
// Copy the alias back to p (excluding unknownFields which we'll set separately)
61+
*p = Policy(alias)
62+
63+
// Extract unknown fields
64+
p.unknownFields = make(map[string]json.RawMessage)
65+
for key, value := range allFields {
66+
if !knownPolicyFields[key] {
67+
p.unknownFields[key] = value
68+
}
69+
}
70+
71+
return nil
72+
}
73+
74+
// MarshalJSON implements custom JSON marshaling that includes unknown fields.
75+
func (p Policy) MarshalJSON() ([]byte, error) {
76+
// Use an alias type to avoid infinite recursion
77+
type PolicyAlias Policy
78+
alias := PolicyAlias(p)
79+
80+
// Marshal the known fields first
81+
knownData, err := json.Marshal(alias)
82+
if err != nil {
83+
return nil, err
84+
}
85+
86+
// If no unknown fields, return as-is
87+
if len(p.unknownFields) == 0 {
88+
return knownData, nil
89+
}
90+
91+
// Unmarshal known fields into a map so we can add unknown fields
92+
var result map[string]json.RawMessage
93+
if err := json.Unmarshal(knownData, &result); err != nil {
94+
return nil, err
95+
}
96+
97+
// Add unknown fields
98+
for key, value := range p.unknownFields {
99+
result[key] = value
100+
}
101+
102+
return json.Marshal(result)
39103
}
40104

41105
// ExtensionSetting represents settings for a specific extension
@@ -54,19 +118,11 @@ func (p *Policy) readPolicyUnlocked() (*Policy, error) {
54118
data, err := os.ReadFile(PolicyPath)
55119
if err != nil {
56120
if os.IsNotExist(err) {
57-
// Return default policy if file doesn't exist
121+
// Return minimal policy if file doesn't exist.
122+
// In practice, policy.json ships with the container image.
58123
return &Policy{
59-
PasswordManagerEnabled: false,
60-
AutofillCreditCardEnabled: false,
61-
TranslateEnabled: false,
62-
DefaultNotificationsSetting: 2,
63-
DefaultSearchProviderEnabled: true,
64-
DefaultSearchProviderName: DefaultSearchProviderName,
65-
DefaultSearchProviderSearchURL: DefaultSearchProviderSearchURL,
66-
DefaultSearchProviderSuggestURL: DefaultSearchProviderSuggestURL,
67-
NewTabPageLocation: NewTabPageLocation,
68-
ExtensionInstallForcelist: []string{},
69-
ExtensionSettings: make(map[string]ExtensionSetting),
124+
ExtensionInstallForcelist: []string{},
125+
ExtensionSettings: make(map[string]ExtensionSetting),
70126
}, nil
71127
}
72128
return nil, fmt.Errorf("failed to read policy file: %w", err)

server/lib/policy/policy_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package policy
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestPolicy_PreservesUnknownFields(t *testing.T) {
12+
// Simulate the policy.json with many fields not in the struct
13+
// Only ExtensionInstallForcelist and ExtensionSettings are in the struct
14+
input := `{
15+
"PasswordManagerEnabled": false,
16+
"AutofillCreditCardEnabled": false,
17+
"TranslateEnabled": false,
18+
"DefaultNotificationsSetting": 2,
19+
"DefaultGeolocationSetting": 2,
20+
"DefaultSearchProviderEnabled": true,
21+
"DefaultSearchProviderName": "DuckDuckGo",
22+
"DefaultSearchProviderSearchURL": "https://duckduckgo.com/?q={searchTerms}",
23+
"DefaultSearchProviderSuggestURL": "https://duckduckgo.com/ac/?q={searchTerms}",
24+
"NewTabPageLocation": "https://start.duckduckgo.com/",
25+
"SomeOtherUnknownField": {"nested": "value"},
26+
"ExtensionSettings": {
27+
"*": {
28+
"allowed_types": ["extension"],
29+
"install_sources": ["*"]
30+
}
31+
}
32+
}`
33+
34+
var policy Policy
35+
err := json.Unmarshal([]byte(input), &policy)
36+
require.NoError(t, err)
37+
38+
// Verify known struct fields are parsed correctly
39+
assert.NotNil(t, policy.ExtensionSettings)
40+
assert.Contains(t, policy.ExtensionSettings, "*")
41+
42+
// Verify all non-struct fields are captured as unknown
43+
// All fields except ExtensionSettings should be in unknownFields
44+
expectedUnknownFields := []string{
45+
"PasswordManagerEnabled",
46+
"AutofillCreditCardEnabled",
47+
"TranslateEnabled",
48+
"DefaultNotificationsSetting",
49+
"DefaultGeolocationSetting",
50+
"DefaultSearchProviderEnabled",
51+
"DefaultSearchProviderName",
52+
"DefaultSearchProviderSearchURL",
53+
"DefaultSearchProviderSuggestURL",
54+
"NewTabPageLocation",
55+
"SomeOtherUnknownField",
56+
}
57+
for _, field := range expectedUnknownFields {
58+
assert.Contains(t, policy.unknownFields, field, "field %s should be preserved", field)
59+
}
60+
61+
// Now marshal it back
62+
output, err := json.Marshal(&policy)
63+
require.NoError(t, err)
64+
65+
// Unmarshal into a map to verify all fields are present
66+
var result map[string]interface{}
67+
err = json.Unmarshal(output, &result)
68+
require.NoError(t, err)
69+
70+
// Verify all fields are preserved
71+
assert.Equal(t, float64(2), result["DefaultGeolocationSetting"])
72+
assert.Equal(t, float64(2), result["DefaultNotificationsSetting"])
73+
assert.Equal(t, false, result["PasswordManagerEnabled"])
74+
assert.Equal(t, "DuckDuckGo", result["DefaultSearchProviderName"])
75+
assert.NotNil(t, result["SomeOtherUnknownField"])
76+
}
77+
78+
func TestPolicy_ModifyAndPreserveUnknownFields(t *testing.T) {
79+
// This test simulates the AddExtension read-modify-write cycle
80+
input := `{
81+
"PasswordManagerEnabled": false,
82+
"DefaultGeolocationSetting": 2,
83+
"DefaultNotificationsSetting": 2,
84+
"ExtensionSettings": {}
85+
}`
86+
87+
var policy Policy
88+
err := json.Unmarshal([]byte(input), &policy)
89+
require.NoError(t, err)
90+
91+
// Add an extension setting (this is what AddExtension does)
92+
policy.ExtensionSettings["test-extension"] = ExtensionSetting{
93+
UpdateUrl: "http://127.0.0.1:10001/extensions/test/update.xml",
94+
}
95+
96+
// Marshal it back
97+
output, err := json.Marshal(&policy)
98+
require.NoError(t, err)
99+
100+
// Verify the unknown fields are still there
101+
var result map[string]interface{}
102+
err = json.Unmarshal(output, &result)
103+
require.NoError(t, err)
104+
105+
assert.Equal(t, float64(2), result["DefaultGeolocationSetting"], "DefaultGeolocationSetting should be preserved")
106+
assert.Equal(t, float64(2), result["DefaultNotificationsSetting"], "DefaultNotificationsSetting should be preserved")
107+
assert.Equal(t, false, result["PasswordManagerEnabled"], "PasswordManagerEnabled should be preserved")
108+
109+
// Verify extension was added
110+
extSettings, ok := result["ExtensionSettings"].(map[string]interface{})
111+
require.True(t, ok)
112+
assert.Contains(t, extSettings, "test-extension")
113+
}
114+
115+
func TestPolicy_ExtensionInstallForcelist(t *testing.T) {
116+
// Test that ExtensionInstallForcelist works correctly
117+
input := `{
118+
"DefaultGeolocationSetting": 2,
119+
"ExtensionInstallForcelist": ["existing-ext;http://example.com/update.xml"],
120+
"ExtensionSettings": {}
121+
}`
122+
123+
var policy Policy
124+
err := json.Unmarshal([]byte(input), &policy)
125+
require.NoError(t, err)
126+
127+
// Verify forcelist is parsed
128+
assert.Len(t, policy.ExtensionInstallForcelist, 1)
129+
assert.Equal(t, "existing-ext;http://example.com/update.xml", policy.ExtensionInstallForcelist[0])
130+
131+
// Add to forcelist
132+
policy.ExtensionInstallForcelist = append(policy.ExtensionInstallForcelist, "new-ext;http://example.com/new.xml")
133+
134+
// Marshal back
135+
output, err := json.Marshal(&policy)
136+
require.NoError(t, err)
137+
138+
var result map[string]interface{}
139+
err = json.Unmarshal(output, &result)
140+
require.NoError(t, err)
141+
142+
// Both unknown field and forcelist should be present
143+
assert.Equal(t, float64(2), result["DefaultGeolocationSetting"])
144+
forcelist, ok := result["ExtensionInstallForcelist"].([]interface{})
145+
require.True(t, ok)
146+
assert.Len(t, forcelist, 2)
147+
}
148+
149+
func TestPolicy_EmptyPolicy(t *testing.T) {
150+
// Test with minimal input
151+
input := `{}`
152+
153+
var policy Policy
154+
err := json.Unmarshal([]byte(input), &policy)
155+
require.NoError(t, err)
156+
157+
assert.Empty(t, policy.unknownFields)
158+
assert.Nil(t, policy.ExtensionSettings)
159+
assert.Nil(t, policy.ExtensionInstallForcelist)
160+
161+
output, err := json.Marshal(&policy)
162+
require.NoError(t, err)
163+
164+
var result map[string]interface{}
165+
err = json.Unmarshal(output, &result)
166+
require.NoError(t, err)
167+
168+
// Should have empty ExtensionSettings as null/missing
169+
assert.Nil(t, result["ExtensionSettings"])
170+
}

0 commit comments

Comments
 (0)