diff --git a/server/cmd/api/api/chromium_configure.go b/server/cmd/api/api/chromium_configure.go index 1e618597..75b59495 100644 --- a/server/cmd/api/api/chromium_configure.go +++ b/server/cmd/api/api/chromium_configure.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "context" "encoding/json" "errors" @@ -315,10 +316,8 @@ func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumC if err != nil { return cfg500ConfigureStep(chromiumConfigureStepProfile, err.Error()) } - if spec.needsNav { - if err := stripProfileSessionRestore(preparedProfile); err != nil { - return cfg500ConfigureStep(chromiumConfigureStepProfile, err.Error()) - } + if err := sanitizePreparedProfile(ctx, preparedProfile, spec.needsNav); err != nil { + return cfg500ConfigureStep(chromiumConfigureStepProfile, err.Error()) } if err := chromiumInstallPreparedProfile(preparedProfile); err != nil { return cfg500ConfigureStep(chromiumConfigureStepProfile, err.Error()) @@ -708,6 +707,22 @@ func chromiumPrepareProfileArchive(profilePath string, strip int) (preparedDir s return preparedDir, cleanup, nil } +// sanitizePreparedProfile edits the unpacked profile before it replaces +// user-data. Session restore is stripped only when a start_url will be +// navigated; Kernel settings are always stripped from Local State, and a +// failure there is logged rather than failing the load. +func sanitizePreparedProfile(ctx context.Context, preparedDir string, stripSessionRestore bool) error { + if stripSessionRestore { + if err := stripProfileSessionRestore(preparedDir); err != nil { + return err + } + } + if err := stripProfileKernelLocalState(preparedDir); err != nil { + logger.FromContext(ctx).Warn("failed to strip Kernel settings from profile Local State", "error", err) + } + return nil +} + // stripProfileSessionRestore deletes the prepared profile's Default/Sessions so // Chrome cannot restore its saved tabs after the restart and race the start_url // navigation. Only the live copy is touched; the stored archive is unchanged. @@ -718,6 +733,44 @@ func stripProfileSessionRestore(preparedDir string) error { return nil } +// stripProfileKernelLocalState removes the top-level "kernel" object from the +// prepared profile's Local State. Kernel's Chromium build stores browser-wide +// settings set over CDP there (fingerprint config, request header rules), and +// they belong to the browser that set them, not to the profile. Only the live +// copy is touched; the stored archive is unchanged. +func stripProfileKernelLocalState(preparedDir string) error { + path := filepath.Join(preparedDir, "Local State") + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("read Local State: %w", err) + } + + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var state map[string]any + if err := dec.Decode(&state); err != nil { + return fmt.Errorf("parse Local State: %w", err) + } + if _, ok := state["kernel"]; !ok { + return nil + } + delete(state, "kernel") + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(state); err != nil { + return fmt.Errorf("marshal Local State: %w", err) + } + if err := os.WriteFile(path, bytes.TrimRight(buf.Bytes(), "\n"), 0o644); err != nil { + return fmt.Errorf("write Local State: %w", err) + } + return nil +} + func chromiumInstallPreparedProfile(preparedDir string) error { if preparedDir == "" { return nil diff --git a/server/cmd/api/api/chromium_configure_test.go b/server/cmd/api/api/chromium_configure_test.go index 607acafa..89084b6c 100644 --- a/server/cmd/api/api/chromium_configure_test.go +++ b/server/cmd/api/api/chromium_configure_test.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "context" "errors" "io" "mime/multipart" @@ -162,6 +163,76 @@ func TestStripProfileSessionRestore(t *testing.T) { require.NoError(t, stripProfileSessionRestore(prepared)) } +func TestStripProfileKernelLocalState(t *testing.T) { + prepared := t.TempDir() + path := filepath.Join(prepared, "Local State") + input := `{"kernel":{"fingerprint_config":{"version":1},"request_header_rules":{"generation":2}},"browser":{"last_redirect_origin":"https://a.test/?x=1&y=2"},"variations_seed_date":13350000000000000001}` + require.NoError(t, os.WriteFile(path, []byte(input), 0o600)) + + require.NoError(t, stripProfileKernelLocalState(prepared)) + + got, err := os.ReadFile(path) + require.NoError(t, err) + require.JSONEq(t, `{"browser":{"last_redirect_origin":"https://a.test/?x=1&y=2"},"variations_seed_date":13350000000000000001}`, string(got)) + require.Contains(t, string(got), "13350000000000000001") + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "rewrite keeps the existing file mode") + + // Without Kernel settings the file is left byte-for-byte unchanged. + untouched := "{\"browser\": {}}\n" + require.NoError(t, os.WriteFile(path, []byte(untouched), 0o600)) + require.NoError(t, stripProfileKernelLocalState(prepared)) + got, err = os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, untouched, string(got)) + + // A profile without Local State is a no-op, not an error. + require.NoError(t, os.Remove(path)) + require.NoError(t, stripProfileKernelLocalState(prepared)) + + require.NoError(t, os.WriteFile(path, []byte("{not json"), 0o600)) + require.ErrorContains(t, stripProfileKernelLocalState(prepared), "parse Local State") +} + +func TestSanitizePreparedProfile(t *testing.T) { + newPrepared := func(t *testing.T, localState string) string { + prepared := t.TempDir() + sessions := filepath.Join(prepared, "Default", "Sessions") + require.NoError(t, os.MkdirAll(sessions, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(sessions, "Session_123"), []byte("tabs"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(prepared, "Local State"), []byte(localState), 0o644)) + return prepared + } + const withKernel = `{"kernel":{"fingerprint_config":{"version":1}},"browser":{}}` + + t.Run("strips Kernel settings and session restore before navigation", func(t *testing.T) { + prepared := newPrepared(t, withKernel) + require.NoError(t, sanitizePreparedProfile(context.Background(), prepared, true)) + + require.NoDirExists(t, filepath.Join(prepared, "Default", "Sessions")) + got, err := os.ReadFile(filepath.Join(prepared, "Local State")) + require.NoError(t, err) + require.JSONEq(t, `{"browser":{}}`, string(got)) + }) + + t.Run("strips Kernel settings and keeps session restore without navigation", func(t *testing.T) { + prepared := newPrepared(t, withKernel) + require.NoError(t, sanitizePreparedProfile(context.Background(), prepared, false)) + + require.DirExists(t, filepath.Join(prepared, "Default", "Sessions")) + got, err := os.ReadFile(filepath.Join(prepared, "Local State")) + require.NoError(t, err) + require.JSONEq(t, `{"browser":{}}`, string(got)) + }) + + t.Run("unreadable Local State does not fail the load", func(t *testing.T) { + prepared := newPrepared(t, "{not json") + require.NoError(t, sanitizePreparedProfile(context.Background(), prepared, true)) + require.NoDirExists(t, filepath.Join(prepared, "Default", "Sessions")) + }) +} + func TestChromiumValidateFlags(t *testing.T) { valid := `{"flags":["--kiosk"]}` plan, err := chromiumValidateFlags(&valid)