diff --git a/images/chromium-headful/run-docker.sh b/images/chromium-headful/run-docker.sh index c3a2972b..f99c976f 100755 --- a/images/chromium-headful/run-docker.sh +++ b/images/chromium-headful/run-docker.sh @@ -63,7 +63,7 @@ RUN_ARGS=( -e WIDTH=1920 -e TZ=${TZ:-'America/Los_Angeles'} -e RUN_AS_ROOT="$RUN_AS_ROOT" - --mount type=bind,src="$FLAGS_FILE",dst=/chromium/flags,ro + --mount type=bind,src="$FLAGS_FILE",dst=/chromium/flags ) if [[ -n "${PLAYWRIGHT_ENGINE:-}" ]]; then diff --git a/server/cmd/api/api/chromium.go b/server/cmd/api/api/chromium.go index 20bab42f..39a282f8 100644 --- a/server/cmd/api/api/chromium.go +++ b/server/cmd/api/api/chromium.go @@ -15,6 +15,7 @@ import ( "github.com/onkernel/kernel-images/server/lib/chromiumflags" "github.com/onkernel/kernel-images/server/lib/logger" oapi "github.com/onkernel/kernel-images/server/lib/oapi" + "github.com/onkernel/kernel-images/server/lib/policy" "github.com/onkernel/kernel-images/server/lib/ziputil" ) @@ -157,49 +158,106 @@ func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oap log.Error("failed to chown extension dir", "error", err) return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to chown extension dir"}}, nil } + log.Info("installed extension", "name", p.name) } // Update enterprise policy for extensions that require it + // Track which extensions need --load-extension flags (those NOT using policy installation) + var pathsNeedingFlags []string + for _, p := range items { extensionPath := filepath.Join(extBase, p.name) - extensionID := s.policy.GenerateExtensionID(p.name) + extensionName := p.name manifestPath := filepath.Join(extensionPath, "manifest.json") + updateXMLPath := filepath.Join(extensionPath, "update.xml") // Check if this extension requires enterprise policy requiresEntPolicy, err := s.policy.RequiresEnterprisePolicy(manifestPath) if err != nil { - log.Warn("failed to read manifest for policy check", "error", err, "extension", p.name) + log.Warn("failed to read manifest for policy check", "error", err, "extension", extensionName) // Continue with requiresEntPolicy = false } + // Try to extract Chrome extension ID from update.xml + chromeExtensionID := extensionName + var extractionErr error + if extractedID, err := policy.ExtractExtensionIDFromUpdateXML(updateXMLPath); err == nil { + chromeExtensionID = extractedID + log.Info("extracted Chrome extension ID from update.xml", "name", extensionName, "chromeExtensionID", chromeExtensionID) + } else { + extractionErr = err + log.Info("no Chrome extension ID in update.xml, using name as ID", "name", extensionName, "error", err) + } + if requiresEntPolicy { - log.Info("extension requires enterprise policy", "name", p.name) + log.Info("extension requires enterprise policy", "name", extensionName) + + // Validate that update.xml and .crx files are present for policy-installed extensions + // These files are required for ExtensionInstallForcelist to work + hasUpdateXML := false + hasCRX := false + + if _, err := os.Stat(updateXMLPath); err == nil { + // For policy extensions, update.xml must exist AND be parseable + if extractionErr != nil { + return oapi.UploadExtensionsAndRestart400JSONResponse{ + BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{ + Message: fmt.Sprintf("extension %s requires enterprise policy but update.xml is invalid: %v", extensionName, extractionErr), + }, + }, nil + } + hasUpdateXML = true + log.Info("found update.xml in extension zip", "name", extensionName) + } + + // Look for any .crx file in the directory + entries, err := os.ReadDir(extensionPath) + if err == nil { + for _, entry := range entries { + if !entry.IsDir() && filepath.Ext(entry.Name()) == ".crx" { + hasCRX = true + log.Info("found .crx file in extension zip", "name", extensionName, "crx_file", entry.Name()) + break + } + } + } + + // Fail if policy extension is missing required files + if !hasUpdateXML || !hasCRX { + return oapi.UploadExtensionsAndRestart400JSONResponse{ + BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{ + Message: fmt.Sprintf("extension %s requires enterprise policy (ExtensionInstallForcelist) but is missing required files: update.xml (present: %v), .crx file (present: %v). These files are required for Chrome to install the extension.", extensionName, hasUpdateXML, hasCRX), + }, + }, nil + } + } else { + // Only add --load-extension flags for non-policy extensions + pathsNeedingFlags = append(pathsNeedingFlags, extensionPath) } // Add to enterprise policy - if err := s.policy.AddExtension(extensionID, extensionPath, requiresEntPolicy); err != nil { - log.Error("failed to update enterprise policy", "error", err, "extension", p.name) + // Pass both extensionName (for URL paths) and chromeExtensionID (for policy entries) + if err := s.policy.AddExtension(extensionName, chromeExtensionID, extensionPath, requiresEntPolicy); err != nil { + log.Error("failed to update enterprise policy", "error", err, "extension", extensionName) return oapi.UploadExtensionsAndRestart500JSONResponse{ InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{ - Message: fmt.Sprintf("failed to update enterprise policy for %s: %v", p.name, err), + Message: fmt.Sprintf("failed to update enterprise policy for %s: %v", extensionName, err), }, }, nil } - log.Info("updated enterprise policy", "extension", p.name, "id", extensionID, "requiresEnterprisePolicy", requiresEntPolicy) + log.Info("updated enterprise policy", "extension", extensionName, "chromeExtensionID", chromeExtensionID, "requiresEnterprisePolicy", requiresEntPolicy) } // Build flags overlay file in /chromium/flags, merging with existing flags - var paths []string - for _, p := range items { - paths = append(paths, filepath.Join(extBase, p.name)) - } - - // Create new flags for the uploaded extensions - newTokens := []string{ - fmt.Sprintf("--disable-extensions-except=%s", strings.Join(paths, ",")), - fmt.Sprintf("--load-extension=%s", strings.Join(paths, ",")), + // Only add --load-extension flags for extensions that don't use policy installation + var newTokens []string + if len(pathsNeedingFlags) > 0 { + newTokens = []string{ + fmt.Sprintf("--disable-extensions-except=%s", strings.Join(pathsNeedingFlags, ",")), + fmt.Sprintf("--load-extension=%s", strings.Join(pathsNeedingFlags, ",")), + } } // Merge and write flags diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 6acdcad9..50e377a0 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -124,6 +124,15 @@ func main() { apiService.HandleProcessAttach(w, r, id) }) + // Serve extension files for Chrome policy-installed extensions + // This allows Chrome to download .crx and update.xml files via HTTP + extensionsDir := "/home/kernel/extensions" + r.Get("/extensions/*", func(w http.ResponseWriter, r *http.Request) { + // Serve files from /home/kernel/extensions/ + fs := http.StripPrefix("/extensions/", http.FileServer(http.Dir(extensionsDir))) + fs.ServeHTTP(w, r) + }) + srv := &http.Server{ Addr: fmt.Sprintf(":%d", config.Port), Handler: r, diff --git a/server/e2e/e2e_chromium_test.go b/server/e2e/e2e_chromium_test.go index 8bc813f2..1c08f78c 100644 --- a/server/e2e/e2e_chromium_test.go +++ b/server/e2e/e2e_chromium_test.go @@ -834,22 +834,37 @@ func TestWebBotAuthInstallation(t *testing.T) { // Build mock web-bot-auth extension zip in-memory extDir := t.TempDir() - manifest := `{ - "manifest_version": 3, - "version": "1.0.0", - "name": "Web Bot Auth Mock", - "description": "Mock web-bot-auth extension for testing", - "permissions": [ - "webRequest", - "webRequestBlocking" - ], - "host_permissions": [ - "*://*/*" - ] -}` - err = os.WriteFile(filepath.Join(extDir, "manifest.json"), []byte(manifest), 0600) + + // Create manifest with webRequest permissions to trigger enterprise policy requirement + manifest := map[string]interface{}{ + "manifest_version": 3, + "version": "1.0.0", + "name": "Web Bot Auth Mock", + "description": "Mock web-bot-auth extension for testing", + "permissions": []string{"webRequest", "webRequestBlocking"}, + "host_permissions": []string{""}, + } + manifestJSON, err := json.MarshalIndent(manifest, "", " ") + require.NoError(t, err, "marshal manifest: %v", err) + + err = os.WriteFile(filepath.Join(extDir, "manifest.json"), manifestJSON, 0600) require.NoError(t, err, "write manifest: %v", err) + // Create update.xml required for enterprise policy + updateXMLContent := ` + + + + +` + + err = os.WriteFile(filepath.Join(extDir, "update.xml"), []byte(updateXMLContent), 0600) + require.NoError(t, err, "write update.xml: %v", err) + + // Create a minimal .crx file (just needs to exist for the test) + err = os.WriteFile(filepath.Join(extDir, "web-bot-auth.crx"), []byte("mock crx content"), 0600) + require.NoError(t, err, "write .crx: %v", err) + extZip, err := zipDirToBytes(extDir) require.NoError(t, err, "zip ext: %v", err) @@ -889,30 +904,44 @@ func TestWebBotAuthInstallation(t *testing.T) { err = json.Unmarshal([]byte(policyContent), &policy) require.NoError(t, err, "failed to parse policy.json: %v", err) - // Check ExtensionSettings exists - extensionSettings, ok := policy["ExtensionSettings"].(map[string]interface{}) - require.True(t, ok, "ExtensionSettings not found in policy.json") + // Check ExtensionInstallForcelist exists + extensionInstallForcelist, ok := policy["ExtensionInstallForcelist"].([]interface{}) + require.True(t, ok, "ExtensionInstallForcelist not found in policy.json") + require.GreaterOrEqual(t, len(extensionInstallForcelist), 1, "ExtensionInstallForcelist should have at least 1 entry") - // Check web-bot-auth entry exists - webBotAuth, ok := extensionSettings["web-bot-auth"].(map[string]interface{}) - require.True(t, ok, "web-bot-auth entry not found in ExtensionSettings") - - // Verify installation_mode is force_installed - installationMode, ok := webBotAuth["installation_mode"].(string) - require.True(t, ok, "installation_mode not found in web-bot-auth entry") - require.Equal(t, "force_installed", installationMode, "expected installation_mode to be force_installed") + // Find the web-bot-auth entry in the forcelist + var webBotAuthEntry string + for _, entry := range extensionInstallForcelist { + if entryStr, ok := entry.(string); ok && strings.Contains(entryStr, "web-bot-auth") { + webBotAuthEntry = entryStr + break + } + } + require.NotEmpty(t, webBotAuthEntry, "web-bot-auth entry not found in ExtensionInstallForcelist") - // Verify path - path, ok := webBotAuth["path"].(string) - require.True(t, ok, "path not found in web-bot-auth entry") - require.Equal(t, "/home/kernel/extensions/web-bot-auth", path, "expected path to be /home/kernel/extensions/web-bot-auth") + // Verify the entry format: "extension-id;update_url" + parts := strings.Split(webBotAuthEntry, ";") + require.Len(t, parts, 2, "expected web-bot-auth entry to have format 'extension-id;update_url'") - // Verify runtime_allowed_hosts - runtimeAllowedHosts, ok := webBotAuth["runtime_allowed_hosts"].([]interface{}) - require.True(t, ok, "runtime_allowed_hosts not found in web-bot-auth entry") - require.Len(t, runtimeAllowedHosts, 1, "expected runtime_allowed_hosts to have 1 entry") - require.Equal(t, "*://*/*", runtimeAllowedHosts[0].(string), "expected runtime_allowed_hosts to contain *://*/*") + extensionID := parts[0] + updateURL := parts[1] + logger.Info("[test]", "extension_id", extensionID, "update_url", updateURL) logger.Info("[test]", "result", "web-bot-auth policy verified successfully") } + + // Verify the extension directory exists + { + logger.Info("[test]", "action", "checking extension directory") + dirList, err := execCombinedOutput(ctx, "ls", []string{"-la", "/home/kernel/extensions/web-bot-auth/"}) + require.NoError(t, err, "failed to list extension directory: %v", err) + logger.Info("[test]", "extension_directory_contents", dirList) + + // Verify manifest.json exists (uploaded as part of the extension) + manifestContent, err := execCombinedOutput(ctx, "cat", []string{"/home/kernel/extensions/web-bot-auth/manifest.json"}) + require.NoError(t, err, "failed to read manifest.json: %v", err) + require.Contains(t, manifestContent, "Web Bot Auth Mock", "manifest.json should contain extension name") + + logger.Info("[test]", "result", "extension directory verified successfully") + } } diff --git a/server/lib/policy/policy.go b/server/lib/policy/policy.go index 91861383..3eda5332 100644 --- a/server/lib/policy/policy.go +++ b/server/lib/policy/policy.go @@ -2,13 +2,20 @@ package policy import ( "encoding/json" + "encoding/xml" "fmt" "os" + "regexp" + "slices" + "strings" "sync" ) const PolicyPath = "/etc/chromium/policies/managed/policy.json" +// Chrome extension IDs are 32 lowercase a-p characters +var extensionIDRegex = regexp.MustCompile(`^[a-p]{32}$`) + // Policy represents the Chrome enterprise policy structure type Policy struct { mu sync.Mutex @@ -17,13 +24,14 @@ type Policy struct { AutofillCreditCardEnabled bool `json:"AutofillCreditCardEnabled"` TranslateEnabled bool `json:"TranslateEnabled"` DefaultNotificationsSetting int `json:"DefaultNotificationsSetting"` + ExtensionInstallForcelist []string `json:"ExtensionInstallForcelist,omitempty"` ExtensionSettings map[string]ExtensionSetting `json:"ExtensionSettings"` } // ExtensionSetting represents settings for a specific extension type ExtensionSetting struct { InstallationMode string `json:"installation_mode,omitempty"` - Path string `json:"path,omitempty"` + UpdateUrl string `json:"update_url,omitempty"` AllowedTypes []string `json:"allowed_types,omitempty"` InstallSources []string `json:"install_sources,omitempty"` RuntimeBlockedHosts []string `json:"runtime_blocked_hosts,omitempty"` @@ -42,6 +50,7 @@ func (p *Policy) readPolicyUnlocked() (*Policy, error) { AutofillCreditCardEnabled: false, TranslateEnabled: false, DefaultNotificationsSetting: 2, + ExtensionInstallForcelist: []string{}, ExtensionSettings: make(map[string]ExtensionSetting), }, nil } @@ -58,6 +67,11 @@ func (p *Policy) readPolicyUnlocked() (*Policy, error) { policy.ExtensionSettings = make(map[string]ExtensionSetting) } + // Initialize ExtensionInstallForcelist if it's nil + if policy.ExtensionInstallForcelist == nil { + policy.ExtensionInstallForcelist = []string{} + } + return &policy, nil } @@ -93,8 +107,10 @@ func (p *Policy) WritePolicy(policy *Policy) error { } // AddExtension adds or updates an extension in the policy -// extensionID should be a stable identifier (can be derived from extension path) -func (p *Policy) AddExtension(extensionID, extensionPath string, requiresEnterprisePolicy bool) error { +// extensionName is the user-provided name used for the directory and URL paths +// chromeExtensionID is the actual Chrome extension ID (from update.xml appid) used in policy entries +// extensionPath is the full path to the unpacked extension directory +func (p *Policy) AddExtension(extensionName, chromeExtensionID, extensionPath string, requiresEnterprisePolicy bool) error { // Lock for the entire read-modify-write cycle to prevent race conditions p.mu.Lock() defer p.mu.Unlock() @@ -113,22 +129,45 @@ func (p *Policy) AddExtension(extensionID, extensionPath string, requiresEnterpr } // Add the specific extension + // Use extension name for the URL path (where files are served) + // Use Chrome extension ID for the policy key (what Chrome expects) setting := ExtensionSetting{ - Path: extensionPath, + UpdateUrl: fmt.Sprintf("http://127.0.0.1:10001/extensions/%s/update.xml", extensionName), } // If the extension requires enterprise policy (like webRequestBlocking), - // set it as force_installed https://github.com/cloudflare/web-bot-auth/blob/main/examples/browser-extension/policy/policy.json.templ + // we need special handling for unpacked extensions loaded via --load-extension + // https://github.com/cloudflare/web-bot-auth/blob/main/examples/browser-extension/policy/policy.json.templ if requiresEnterprisePolicy { + // For unpacked extensions with webRequestBlocking: + // Chrome requires the extension to be in ExtensionInstallForcelist + // Format: "extension_id;update_url" per https://chromeenterprise.google/intl/en_ca/policies/#ExtensionInstallForcelist setting.InstallationMode = "force_installed" - // Allow all hosts for webRequest APIs - setting.RuntimeAllowedHosts = []string{"*://*/*"} + + // Add to ExtensionInstallForcelist using the Chrome extension ID and update URL + forcelistEntry := fmt.Sprintf("%s;%s", chromeExtensionID, setting.UpdateUrl) + + // Remove any existing entries with the same extension ID (different URLs) + if policy.ExtensionInstallForcelist == nil { + policy.ExtensionInstallForcelist = []string{} + } + + // Filter out entries that start with the same extension ID + extensionIDPrefix := chromeExtensionID + ";" + policy.ExtensionInstallForcelist = slices.DeleteFunc(policy.ExtensionInstallForcelist, func(entry string) bool { + return strings.HasPrefix(entry, extensionIDPrefix) + }) + + // Add the new entry + policy.ExtensionInstallForcelist = append(policy.ExtensionInstallForcelist, forcelistEntry) + + // Use Chrome extension ID as the key in ExtensionSettings + policy.ExtensionSettings[chromeExtensionID] = setting } else { - setting.InstallationMode = "normal_installed" + // For normal extensions, use the extension name as the key + policy.ExtensionSettings[extensionName] = setting } - policy.ExtensionSettings[extensionID] = setting - return p.writePolicyUnlocked(policy) } @@ -168,3 +207,45 @@ func (p *Policy) RequiresEnterprisePolicy(manifestPath string) (bool, error) { return false, nil } + +// updateManifest represents the Chrome extension update manifest XML structure +type updateManifest struct { + XMLName xml.Name `xml:"gupdate"` + Apps []appNode `xml:"app"` +} + +type appNode struct { + AppID string `xml:"appid,attr"` +} + +// ExtractExtensionIDFromUpdateXML reads update.xml and extracts the appid attribute +// from the element. Returns the appid or an error if the file doesn't exist +// or the appid cannot be found. +func ExtractExtensionIDFromUpdateXML(updateXMLPath string) (string, error) { + data, err := os.ReadFile(updateXMLPath) + if err != nil { + return "", fmt.Errorf("failed to read update.xml: %w", err) + } + + var manifest updateManifest + if err := xml.Unmarshal(data, &manifest); err != nil { + return "", fmt.Errorf("failed to parse update.xml: %w", err) + } + + if len(manifest.Apps) == 0 { + return "", fmt.Errorf("no element found in update.xml") + } + + appID := manifest.Apps[0].AppID + if appID == "" { + return "", fmt.Errorf("appid attribute is empty in update.xml") + } + + // Validate extension ID format: Chrome extension IDs are 32 lowercase a-p characters + // This prevents injection attacks via semicolons or other special characters + if !extensionIDRegex.MatchString(appID) { + return "", fmt.Errorf("invalid Chrome extension ID format in update.xml: %s", appID) + } + + return appID, nil +}