diff --git a/server/cmd/chromium-launcher/main.go b/server/cmd/chromium-launcher/main.go index 762167e4..78d614bc 100644 --- a/server/cmd/chromium-launcher/main.go +++ b/server/cmd/chromium-launcher/main.go @@ -13,6 +13,7 @@ import ( "time" "github.com/kernel/kernel-images/server/lib/chromiumflags" + "github.com/kernel/kernel-images/server/lib/policy" "github.com/kernel/kernel-images/server/lib/x11" ) @@ -52,6 +53,11 @@ func main() { internalPort = "9223" } + if err := (&policy.Policy{}).ApplyURLBlocklistGuard(policy.RecursionGuardURLBlocklistFromEnv()); err != nil { + fmt.Fprintf(os.Stderr, "failed applying chromium recursion guard policy: %v\n", err) + os.Exit(1) + } + // Wait for devtools port to be available (handles SIGKILL socket cleanup delay) waitForPort(internalPort, 5*time.Second) diff --git a/server/lib/policy/recursion_guard.go b/server/lib/policy/recursion_guard.go new file mode 100644 index 00000000..8299aa0f --- /dev/null +++ b/server/lib/policy/recursion_guard.go @@ -0,0 +1,115 @@ +package policy + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +const ( + // ChromiumRecursionGuardURLBlocklistEnv configures URLBlocklist entries + // that are always merged into Chromium policy before the browser starts. + // Values may be comma- or newline-separated. Unset or blank uses the default. + // Set to "0", "false", "off", or "none" to disable the guard explicitly. + ChromiumRecursionGuardURLBlocklistEnv = "CHROMIUM_RECURSION_GUARD_URL_BLOCKLIST" + + // Chrome URLBlocklist matches a bare host and any subdomain; a leading dot + // would disable subdomain matching. + // The query is intentionally omitted so livestream JWT variations are + // covered without per-request proxy work. + DefaultChromiumRecursionGuardURLBlocklist = "https://onkernel.com:8443/browser/live" +) + +// RecursionGuardURLBlocklistFromEnv returns the configured recursion guard +// URLBlocklist entries. The default blocks deployed live-browser capture URLs. +func RecursionGuardURLBlocklistFromEnv() []string { + return recursionGuardURLBlocklistFromLookup(os.LookupEnv) +} + +func recursionGuardURLBlocklistFromLookup(lookup func(string) (string, bool)) []string { + value, ok := lookup(ChromiumRecursionGuardURLBlocklistEnv) + if !ok || strings.TrimSpace(value) == "" { + return []string{DefaultChromiumRecursionGuardURLBlocklist} + } + + value = strings.TrimSpace(value) + switch strings.ToLower(value) { + case "0", "false", "off", "none": + return nil + } + + return splitURLBlocklist(value) +} + +func splitURLBlocklist(value string) []string { + fields := strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == '\n' || r == '\r' + }) + + out := make([]string, 0, len(fields)) + for _, field := range fields { + if v := strings.TrimSpace(field); v != "" { + out = append(out, v) + } + } + return out +} + +// ApplyURLBlocklistGuard merges guard entries into Chrome's URLBlocklist policy +// without removing customer-provided blocklist entries. +func (p *Policy) ApplyURLBlocklistGuard(entries []string) error { + entries = uniqueStrings(entries) + if len(entries) == 0 { + return nil + } + + return p.Modify(func(current *Policy) error { + return mergeURLBlocklistGuard(current, entries) + }) +} + +func mergeURLBlocklistGuard(current *Policy, entries []string) error { + entries = uniqueStrings(entries) + if len(entries) == 0 { + return nil + } + + if current.unknownFields == nil { + current.unknownFields = make(map[string]json.RawMessage) + } + + var blocklist []string + if raw, ok := current.unknownFields["URLBlocklist"]; ok && len(raw) > 0 && string(raw) != "null" { + if err := json.Unmarshal(raw, &blocklist); err != nil { + return fmt.Errorf("failed to parse existing URLBlocklist policy: %w", err) + } + } + + blocklist = append(blocklist, entries...) + blocklist = uniqueStrings(blocklist) + + raw, err := json.Marshal(blocklist) + if err != nil { + return fmt.Errorf("failed to marshal URLBlocklist policy: %w", err) + } + current.unknownFields["URLBlocklist"] = raw + return nil +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} diff --git a/server/lib/policy/recursion_guard_test.go b/server/lib/policy/recursion_guard_test.go new file mode 100644 index 00000000..4df5c312 --- /dev/null +++ b/server/lib/policy/recursion_guard_test.go @@ -0,0 +1,79 @@ +package policy + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseRecursionGuardURLBlocklist_DefaultWhenUnsetOrBlank(t *testing.T) { + unsetLookup := func(string) (string, bool) { return "", false } + blankLookup := func(string) (string, bool) { return " ", true } + + assert.Equal(t, []string{DefaultChromiumRecursionGuardURLBlocklist}, recursionGuardURLBlocklistFromLookup(unsetLookup)) + assert.Equal(t, []string{DefaultChromiumRecursionGuardURLBlocklist}, recursionGuardURLBlocklistFromLookup(blankLookup)) +} + +func TestParseRecursionGuardURLBlocklist_CustomList(t *testing.T) { + setLookup := func(string) (string, bool) { + return "https://example.com:8443/browser/live,\nhttps://capture.example.net/live", true + } + + got := recursionGuardURLBlocklistFromLookup(setLookup) + + assert.Equal(t, []string{ + "https://example.com:8443/browser/live", + "https://capture.example.net/live", + }, got) +} + +func TestParseRecursionGuardURLBlocklist_DisableValues(t *testing.T) { + for _, value := range []string{"0", "false", "off", "none"} { + t.Run(value, func(t *testing.T) { + setLookup := func(string) (string, bool) { return value, true } + assert.Nil(t, recursionGuardURLBlocklistFromLookup(setLookup)) + }) + } +} + +func TestMergeURLBlocklistGuard_AppendsWithoutDroppingExistingEntries(t *testing.T) { + current := &Policy{ + ExtensionSettings: map[string]ExtensionSetting{}, + unknownFields: map[string]json.RawMessage{ + "URLBlocklist": json.RawMessage(`["https://blocked.example.com/*"]`), + }, + } + + err := mergeURLBlocklistGuard(current, []string{ + DefaultChromiumRecursionGuardURLBlocklist, + DefaultChromiumRecursionGuardURLBlocklist, + }) + require.NoError(t, err) + + var blocklist []string + require.NoError(t, json.Unmarshal(current.unknownFields["URLBlocklist"], &blocklist)) + assert.Equal(t, []string{ + "https://blocked.example.com/*", + DefaultChromiumRecursionGuardURLBlocklist, + }, blocklist) +} + +func TestMergeURLBlocklistGuard_PreservesOtherUnknownPolicyFields(t *testing.T) { + current := &Policy{ + ExtensionSettings: map[string]ExtensionSetting{}, + unknownFields: map[string]json.RawMessage{ + "PasswordManagerEnabled": json.RawMessage(`false`), + }, + } + + err := mergeURLBlocklistGuard(current, []string{DefaultChromiumRecursionGuardURLBlocklist}) + require.NoError(t, err) + + assert.JSONEq(t, `false`, string(current.unknownFields["PasswordManagerEnabled"])) + + var blocklist []string + require.NoError(t, json.Unmarshal(current.unknownFields["URLBlocklist"], &blocklist)) + assert.Equal(t, []string{DefaultChromiumRecursionGuardURLBlocklist}, blocklist) +}