diff --git a/images/chromium-headless/image/wrapper.sh b/images/chromium-headless/image/wrapper.sh index db58b8dd..7c72ca05 100755 --- a/images/chromium-headless/image/wrapper.sh +++ b/images/chromium-headless/image/wrapper.sh @@ -44,13 +44,15 @@ fi export HOSTNAME="${HOSTNAME:-kernel-vm}" # if CHROMIUM_FLAGS is not set, default to the flags used in playwright_stealth +# NOTE: --disable-background-networking was intentionally removed because it prevents +# Chrome from fetching extensions via ExtensionInstallForcelist enterprise policy. +# Enterprise extensions require Chrome to make HTTP requests to fetch update.xml and .crx files. if [ -z "${CHROMIUM_FLAGS:-}" ]; then CHROMIUM_FLAGS="--accept-lang=en-US,en \ --allow-pre-commit-input \ --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 \ --crash-dumps-dir=/tmp/chromium-dumps \ --disable-back-forward-cache \ - --disable-background-networking \ --disable-background-timer-throttling \ --disable-backgrounding-occluded-windows \ --disable-blink-features=AutomationControlled \ diff --git a/server/cmd/api/api/chromium.go b/server/cmd/api/api/chromium.go index 905b8721..20d940ca 100644 --- a/server/cmd/api/api/chromium.go +++ b/server/cmd/api/api/chromium.go @@ -260,10 +260,14 @@ func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oap // Build flags overlay file in /chromium/flags, merging with existing flags // Only add --load-extension flags for extensions that don't use policy installation + // NOTE: We intentionally do NOT use --disable-extensions-except here because it causes + // Chrome to disable external providers (including the policy loader), which prevents + // enterprise policy extensions (ExtensionInstallForcelist) from being fetched and installed. + // See Chromium source: extension_service.cc - external providers are only created when + // extensions_enabled() returns true, which is false when --disable-extensions-except is used. 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, ",")), } } diff --git a/server/e2e/e2e_enterprise_extension_test.go b/server/e2e/e2e_enterprise_extension_test.go new file mode 100644 index 00000000..37b36010 --- /dev/null +++ b/server/e2e/e2e_enterprise_extension_test.go @@ -0,0 +1,591 @@ +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "mime/multipart" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + logctx "github.com/onkernel/kernel-images/server/lib/logger" + "github.com/stretchr/testify/require" +) + +// TestEnterpriseExtensionInstallation tests that enterprise policy extensions +// (with update.xml and .crx files) are installed correctly via ExtensionInstallForcelist. +// +// This test verifies: +// 1. Extension with webRequest permission and update.xml/.crx files is uploaded successfully +// 2. Enterprise policy (ExtensionInstallForcelist) is correctly configured +// 3. Chrome fetches the update.xml and downloads the .crx file +// 4. Extension is installed and appears in chrome://extensions +// +// This test uses a real built extension (web-bot-auth) to reproduce production behavior. +// It runs against both headless and headful Chrome images. +func TestEnterpriseExtensionInstallation(t *testing.T) { + ensurePlaywrightDeps(t) + + testCases := []struct { + name string + image string + }{ + {"Headless", headlessImage}, + {"Headful", headfulImage}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runEnterpriseExtensionTest(t, tc.image) + }) + } +} + +func runEnterpriseExtensionTest(t *testing.T, image string) { + name := containerName + "-enterprise-ext" + + logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug})) + baseCtx := logctx.AddToContext(context.Background(), logger) + + if _, err := exec.LookPath("docker"); err != nil { + require.NoError(t, err, "docker not available: %v", err) + } + + // Clean slate + _ = stopContainer(baseCtx, name) + + // Use default CHROMIUM_FLAGS - the images now have --disable-background-networking removed + // (headless) or never had it (headful), allowing Chrome to fetch extensions via + // ExtensionInstallForcelist enterprise policy + env := map[string]string{} + + // Start container + _, exitCh, err := runContainer(baseCtx, image, name, env) + require.NoError(t, err, "failed to start container: %v", err) + defer stopContainer(baseCtx, name) + + ctx, cancel := context.WithTimeout(baseCtx, 5*time.Minute) + defer cancel() + + logger.Info("[setup]", "action", "waiting for API", "image", image, "url", apiBaseURL+"/spec.yaml") + require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready") + + // Wait for DevTools to be ready + _, err = waitDevtoolsWS(ctx) + require.NoError(t, err, "devtools not ready") + + // First upload a simple extension to simulate the kernel extension in production. + // This causes Chrome to be launched with --load-extension, which mirrors production + // where the kernel extension is always loaded before any enterprise extensions. + logger.Info("[test]", "action", "uploading kernel-like extension first (to simulate prod)") + uploadKernelLikeExtension(t, ctx, logger) + + // Wait for Chrome to restart with the new flags + time.Sleep(3 * time.Second) + _, err = waitDevtoolsWS(ctx) + require.NoError(t, err, "devtools not ready after kernel extension") + + // Upload the enterprise test extension (with update.xml and .crx) + logger.Info("[test]", "action", "uploading enterprise test extension (with update.xml and .crx)") + uploadEnterpriseTestExtension(t, ctx, logger) + + // Wait a bit for Chrome to process the enterprise policy + logger.Info("[test]", "action", "waiting for Chrome to process enterprise policy") + time.Sleep(5 * time.Second) + + // Check what files were extracted on the server + logger.Info("[test]", "action", "checking extracted extension files on server") + checkExtractedFiles(t, ctx, logger) + + // Check the kernel-images-api logs for extension download requests + logger.Info("[test]", "action", "checking if Chrome fetched the extension") + checkExtensionDownloadLogs(t, ctx, logger) + + // Verify enterprise policy was configured correctly + logger.Info("[test]", "action", "verifying enterprise policy configuration") + verifyEnterprisePolicy(t, ctx, logger) + + // Wait longer and check again if Chrome has downloaded the extension + logger.Info("[test]", "action", "waiting for Chrome to download extension via enterprise policy") + time.Sleep(30 * time.Second) + + // Check logs again + checkExtensionDownloadLogs(t, ctx, logger) + + // Check Chrome's extension installation logs + logger.Info("[test]", "action", "checking Chrome stderr for extension-related logs") + checkChromiumLogs(t, ctx, logger) + + // Try to trigger extension installation by restarting Chrome + logger.Info("[test]", "action", "restarting Chrome to trigger policy refresh") + restartChrome(t, ctx, logger) + + time.Sleep(15 * time.Second) + + // Check logs one more time + checkExtensionDownloadLogs(t, ctx, logger) + checkChromiumLogs(t, ctx, logger) + + // Check Chrome's policy state + logger.Info("[test]", "action", "checking Chrome policy state") + checkChromePolicies(t, ctx, logger) + + // Check chrome://policy to see if Chrome recognizes the policy + logger.Info("[test]", "action", "checking chrome://policy via screenshot") + takeChromePolicyScreenshot(t, ctx, logger) + + // Verify the extension is installed + logger.Info("[test]", "action", "checking if extension is installed in Chrome's user-data") + verifyExtensionInstalled(t, ctx, logger) + + logger.Info("[test]", "result", "enterprise extension installation test completed") +} + +// uploadKernelLikeExtension uploads a simple extension to simulate the kernel extension. +// In production, the kernel extension is always loaded before any enterprise extensions, +// so this ensures the test mirrors that behavior. +func uploadKernelLikeExtension(t *testing.T, ctx context.Context, logger *slog.Logger) { + t.Helper() + + client, err := apiClient() + require.NoError(t, err, "failed to create API client") + + // Get the path to the simple test extension (no webRequest, so no enterprise policy) + extDir, err := filepath.Abs("test-extension") + require.NoError(t, err, "failed to get absolute path to test-extension") + + // Create zip of the extension + extZip, err := zipDirToBytes(extDir) + require.NoError(t, err, "failed to zip test extension") + + // Upload extension + var body bytes.Buffer + w := multipart.NewWriter(&body) + fw, err := w.CreateFormFile("extensions.zip_file", "kernel-like-ext.zip") + require.NoError(t, err) + _, err = io.Copy(fw, bytes.NewReader(extZip)) + require.NoError(t, err) + err = w.WriteField("extensions.name", "kernel") + require.NoError(t, err) + err = w.Close() + require.NoError(t, err) + + start := time.Now() + rsp, err := client.UploadExtensionsAndRestartWithBodyWithResponse(ctx, w.FormDataContentType(), &body) + elapsed := time.Since(start) + require.NoError(t, err, "uploadExtensionsAndRestart request error") + + require.Equal(t, http.StatusCreated, rsp.StatusCode(), + "expected 201 Created but got %d. Body: %s", + rsp.StatusCode(), string(rsp.Body)) + + logger.Info("[kernel-ext]", "action", "uploaded kernel-like extension", "elapsed", elapsed.String()) +} + +// uploadEnterpriseTestExtension uploads the test extension with update.xml and .crx files. +// This should trigger enterprise policy handling via ExtensionInstallForcelist. +func uploadEnterpriseTestExtension(t *testing.T, ctx context.Context, logger *slog.Logger) { + t.Helper() + + client, err := apiClient() + require.NoError(t, err, "failed to create API client") + + // Get the path to the test extension + extDir, err := filepath.Abs("test-extension-enterprise") + require.NoError(t, err, "failed to get absolute path to test-extension-enterprise") + + // Read and log the manifest + manifestPath := filepath.Join(extDir, "manifest.json") + manifestData, err := os.ReadFile(manifestPath) + require.NoError(t, err, "failed to read manifest.json") + logger.Info("[extension]", "manifest", string(manifestData)) + + // Read and log the update.xml + updateXMLPath := filepath.Join(extDir, "update.xml") + updateXMLData, err := os.ReadFile(updateXMLPath) + require.NoError(t, err, "failed to read update.xml") + logger.Info("[extension]", "update.xml", string(updateXMLData)) + + // Verify .crx exists + crxPath := filepath.Join(extDir, "extension.crx") + crxInfo, err := os.Stat(crxPath) + require.NoError(t, err, "failed to stat .crx file") + logger.Info("[extension]", "crx_size", crxInfo.Size()) + + // Create zip of the extension + extZip, err := zipDirToBytes(extDir) + require.NoError(t, err, "failed to zip test extension") + + // Upload extension + var body bytes.Buffer + w := multipart.NewWriter(&body) + fw, err := w.CreateFormFile("extensions.zip_file", "enterprise-test-ext.zip") + require.NoError(t, err) + _, err = io.Copy(fw, bytes.NewReader(extZip)) + require.NoError(t, err) + err = w.WriteField("extensions.name", "enterprise-test") + require.NoError(t, err) + err = w.Close() + require.NoError(t, err) + + start := time.Now() + rsp, err := client.UploadExtensionsAndRestartWithBodyWithResponse(ctx, w.FormDataContentType(), &body) + elapsed := time.Since(start) + require.NoError(t, err, "uploadExtensionsAndRestart request error") + + // The key assertion: this should return 201 + require.Equal(t, http.StatusCreated, rsp.StatusCode(), + "expected 201 Created but got %d. Body: %s", + rsp.StatusCode(), string(rsp.Body)) + + logger.Info("[extension]", "action", "uploaded", "elapsed", elapsed.String()) +} + +// verifyEnterprisePolicy checks that the enterprise policy was configured correctly. +func verifyEnterprisePolicy(t *testing.T, ctx context.Context, logger *slog.Logger) { + t.Helper() + + // Read policy.json + policyContent, err := execCombinedOutput(ctx, "cat", []string{"/etc/chromium/policies/managed/policy.json"}) + require.NoError(t, err, "failed to read policy.json") + logger.Info("[policy]", "content", policyContent) + + var policy map[string]interface{} + err = json.Unmarshal([]byte(policyContent), &policy) + require.NoError(t, err, "failed to parse policy.json") + + // Check ExtensionInstallForcelist exists and contains our extension + 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") + + // Log all entries + for i, entry := range extensionInstallForcelist { + logger.Info("[policy]", "forcelist_entry", i, "value", entry) + } + + // Find the enterprise-test entry + var found bool + for _, entry := range extensionInstallForcelist { + if entryStr, ok := entry.(string); ok && strings.Contains(entryStr, "enterprise-test") { + found = true + logger.Info("[policy]", "found_entry", entryStr) + break + } + } + require.True(t, found, "enterprise-test entry not found in ExtensionInstallForcelist") + + // Check ExtensionSettings + extensionSettings, ok := policy["ExtensionSettings"].(map[string]interface{}) + if ok { + logger.Info("[policy]", "extension_settings", fmt.Sprintf("%+v", extensionSettings)) + } +} + +// checkExtractedFiles checks what files were extracted on the server side. +func checkExtractedFiles(t *testing.T, ctx context.Context, logger *slog.Logger) { + t.Helper() + + // List all files in the extension directory + output, err := execCombinedOutput(ctx, "ls", []string{"-la", "/home/kernel/extensions/enterprise-test/"}) + if err != nil { + logger.Warn("[files]", "error", err.Error()) + } else { + logger.Info("[files]", "extension_dir", output) + } + + // Check if update.xml exists + updateXML, err := execCombinedOutput(ctx, "cat", []string{"/home/kernel/extensions/enterprise-test/update.xml"}) + if err != nil { + logger.Warn("[files]", "update_xml_error", err.Error()) + } else { + logger.Info("[files]", "update.xml", updateXML) + } + + // Check if .crx exists + crxOutput, err := execCombinedOutput(ctx, "ls", []string{"-la", "/home/kernel/extensions/enterprise-test/*.crx"}) + if err != nil { + logger.Warn("[files]", "crx_error", err.Error()) + } else { + logger.Info("[files]", "crx_files", crxOutput) + } + + // Check file types + fileOutput, err := execCombinedOutput(ctx, "file", []string{"/home/kernel/extensions/enterprise-test/extension.crx"}) + if err != nil { + logger.Warn("[files]", "file_type_error", err.Error()) + } else { + logger.Info("[files]", "crx_file_type", fileOutput) + } +} + +// checkExtensionDownloadLogs checks the kernel-images-api logs for extension download requests. +func checkExtensionDownloadLogs(t *testing.T, ctx context.Context, logger *slog.Logger) { + t.Helper() + + // Check kernel-images-api log for requests to update.xml and .crx + apiLog, err := execCombinedOutput(ctx, "cat", []string{"/var/log/supervisord/kernel-images-api"}) + if err != nil { + logger.Warn("[logs]", "error", err.Error()) + return + } + + lines := strings.Split(apiLog, "\n") + for _, line := range lines { + if strings.Contains(line, "update.xml") || strings.Contains(line, ".crx") || strings.Contains(line, "extension") { + logger.Info("[logs]", "line", line) + } + } + + // Check specifically for GET requests to our extension + if strings.Contains(apiLog, "GET") && strings.Contains(apiLog, "enterprise-test") { + logger.Info("[logs]", "result", "Chrome made GET requests to fetch the extension!") + } else { + logger.Warn("[logs]", "result", "No GET requests to enterprise-test extension found") + } + + // Log all GET requests + for _, line := range lines { + if strings.Contains(line, "GET") { + logger.Info("[logs]", "GET_request", line) + } + } +} + +// checkChromePolicies checks how Chrome sees the policies. +func checkChromePolicies(t *testing.T, ctx context.Context, logger *slog.Logger) { + t.Helper() + + // Check Chrome's local state for policy info + localState, err := execCombinedOutput(ctx, "cat", []string{"/home/kernel/user-data/Local State"}) + if err != nil { + logger.Warn("[policies]", "local_state_error", err.Error()) + } else { + // Try to parse and look for extension-related info + var state map[string]interface{} + if err := json.Unmarshal([]byte(localState), &state); err != nil { + logger.Warn("[policies]", "parse_error", err.Error()) + } else { + // Look for extensions in local state + if ext, ok := state["extensions"]; ok { + logger.Info("[policies]", "extensions_in_local_state", fmt.Sprintf("%+v", ext)) + } + } + } + + // Check if Chrome has read the policy file + // chrome://policy data could be extracted via CDP but that's complex + // Instead, let's check if there's any extension component data + extSettingsPath := "/home/kernel/user-data/Default/Extension Settings" + extSettings, err := execCombinedOutput(ctx, "ls", []string{"-la", extSettingsPath}) + if err != nil { + logger.Warn("[policies]", "ext_settings_dir_error", err.Error()) + } else { + logger.Info("[policies]", "ext_settings_dir", extSettings) + } +} + +// checkChromiumLogs checks Chrome's logs for extension-related messages. +func checkChromiumLogs(t *testing.T, ctx context.Context, logger *slog.Logger) { + t.Helper() + + // Check chromium supervisor log for extension-related messages + chromiumLog, err := execCombinedOutput(ctx, "cat", []string{"/var/log/supervisord/chromium"}) + if err != nil { + logger.Warn("[chromium-log]", "error", err.Error()) + return + } + + lines := strings.Split(chromiumLog, "\n") + for _, line := range lines { + lowLine := strings.ToLower(line) + if strings.Contains(lowLine, "extension") || + strings.Contains(lowLine, "policy") || + strings.Contains(lowLine, "crx") || + strings.Contains(lowLine, "update") || + strings.Contains(lowLine, "error") || + strings.Contains(lowLine, "fail") { + logger.Info("[chromium-log]", "line", line) + } + } + + // Also check stdout/stderr for the last 100 lines + logger.Info("[chromium-log]", "action", "checking last 100 lines of chromium log") + tailOutput, err := execCombinedOutput(ctx, "tail", []string{"-n", "100", "/var/log/supervisord/chromium"}) + if err != nil { + logger.Warn("[chromium-log]", "tail_error", err.Error()) + } else { + logger.Info("[chromium-log]", "last_100_lines", tailOutput) + } +} + +// restartChrome restarts Chrome via supervisorctl. +func restartChrome(t *testing.T, ctx context.Context, logger *slog.Logger) { + t.Helper() + + output, err := execCombinedOutput(ctx, "supervisorctl", []string{"-c", "/etc/supervisor/supervisord.conf", "restart", "chromium"}) + if err != nil { + logger.Warn("[restart]", "error", err.Error(), "output", output) + } else { + logger.Info("[restart]", "result", output) + } +} + +// takeChromePolicyScreenshot takes a screenshot of chrome://policy to debug what Chrome sees +func takeChromePolicyScreenshot(t *testing.T, ctx context.Context, logger *slog.Logger) { + t.Helper() + + // Use the API to take a screenshot after navigating to chrome://policy + client, err := apiClient() + if err != nil { + logger.Warn("[policy-screenshot]", "client_error", err.Error()) + return + } + + // Navigate using playwright then take screenshot + cmd := exec.CommandContext(ctx, "pnpm", "exec", "tsx", "-e", ` +const { chromium } = require('playwright-core'); + +(async () => { + const browser = await chromium.connectOverCDP('ws://127.0.0.1:9222/'); + const contexts = browser.contexts(); + const ctx = contexts[0] || await browser.newContext(); + const pages = ctx.pages(); + const page = pages[0] || await ctx.newPage(); + + // Go to extensions page first to check for extension errors + console.log('=== CHECKING EXTENSIONS ==='); + await page.goto('chrome://extensions'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + + // Use evaluate to pierce shadow DOM and get extension info + const extensionInfo = await page.evaluate(() => { + const manager = document.querySelector('extensions-manager'); + if (!manager || !manager.shadowRoot) return { error: 'no extensions-manager' }; + + const itemList = manager.shadowRoot.querySelector('extensions-item-list'); + if (!itemList || !itemList.shadowRoot) return { error: 'no item-list' }; + + const items = itemList.shadowRoot.querySelectorAll('extensions-item'); + const extensions = []; + + for (const item of items) { + if (!item.shadowRoot) continue; + const nameEl = item.shadowRoot.querySelector('#name'); + const name = nameEl?.textContent?.trim() || 'unknown'; + const id = item.getAttribute('id'); + + // Check for errors + const warningsEl = item.shadowRoot.querySelector('.warning-list'); + const warnings = warningsEl?.textContent?.trim() || ''; + + extensions.push({ name, id, warnings }); + } + + return { extensions }; + }); + + console.log('Extensions found:', JSON.stringify(extensionInfo, null, 2)); + + await browser.close(); +})(); +`) + cmd.Dir = getPlaywrightPath() + out, err := cmd.CombinedOutput() + if err != nil { + logger.Warn("[policy-screenshot]", "error", err.Error(), "output", string(out)) + } else { + logger.Info("[policy-screenshot]", "output", string(out)) + } + + // Ignore client since we used playwright directly + _ = client +} + +// verifyExtensionInstalled checks if the extension was installed by Chrome. +func verifyExtensionInstalled(t *testing.T, ctx context.Context, logger *slog.Logger) { + t.Helper() + + // Check the extension directory + extDir, err := execCombinedOutput(ctx, "ls", []string{"-la", "/home/kernel/extensions/"}) + if err != nil { + logger.Warn("[verify]", "error", err.Error()) + } else { + logger.Info("[verify]", "extensions_dir", extDir) + } + + // Check if Chrome installed the extension using Playwright to inspect chrome://extensions + // Note: When loaded via --load-extension, Chrome generates a NEW extension ID based on the + // directory path, which differs from the ID in update.xml (which is for the packed .crx file). + // So we verify by extension name instead. + + expectedExtensionName := "Minimal Enterprise Test Extension" + logger.Info("[verify]", "expected_extension_name", expectedExtensionName) + + // Use playwright to navigate to chrome://extensions and verify extension is loaded + logger.Info("[verify]", "action", "checking chrome://extensions via playwright") + cmd := exec.CommandContext(ctx, "pnpm", "exec", "tsx", "-e", fmt.Sprintf(` +const { chromium } = require('playwright-core'); + +(async () => { + const browser = await chromium.connectOverCDP('ws://127.0.0.1:9222/'); + const contexts = browser.contexts(); + const ctx = contexts[0] || await browser.newContext(); + const pages = ctx.pages(); + const page = pages[0] || await ctx.newPage(); + + await page.goto('chrome://extensions'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + + const extensionInfo = await page.evaluate(() => { + const manager = document.querySelector('extensions-manager'); + if (!manager || !manager.shadowRoot) return { error: 'no extensions-manager' }; + + const itemList = manager.shadowRoot.querySelector('extensions-item-list'); + if (!itemList || !itemList.shadowRoot) return { error: 'no item-list' }; + + const items = itemList.shadowRoot.querySelectorAll('extensions-item'); + const extensions = []; + + for (const item of items) { + if (!item.shadowRoot) continue; + const nameEl = item.shadowRoot.querySelector('#name'); + const name = nameEl?.textContent?.trim() || 'unknown'; + extensions.push(name); + } + + return { extensions }; + }); + + if (extensionInfo.error) { + console.log('ERROR: ' + extensionInfo.error); + process.exit(1); + } + + const expectedName = %q; + if (extensionInfo.extensions.includes(expectedName)) { + console.log('SUCCESS: Extension "' + expectedName + '" found'); + process.exit(0); + } else { + console.log('FAIL: Extension "' + expectedName + '" not found. Extensions: ' + extensionInfo.extensions.join(', ')); + process.exit(1); + } + + await browser.close(); +})(); +`, expectedExtensionName)) + cmd.Dir = getPlaywrightPath() + out, err := cmd.CombinedOutput() + logger.Info("[playwright]", "output", string(out)) + require.NoError(t, err, "extension verification failed: expected extension %q to be installed in chrome://extensions", expectedExtensionName) +} diff --git a/server/e2e/test-extension-enterprise/background.js b/server/e2e/test-extension-enterprise/background.js new file mode 100644 index 00000000..41832f05 --- /dev/null +++ b/server/e2e/test-extension-enterprise/background.js @@ -0,0 +1,13 @@ +// Minimal enterprise extension background script +// This extension exists to test enterprise policy installation via ExtensionInstallForcelist. +// The webRequest permission requires enterprise policy for installation. + +chrome.webRequest.onBeforeRequest.addListener( + (details) => { + // No-op listener - just to validate the extension loaded correctly + return {}; + }, + { urls: [""] } +); + +console.log("Minimal Enterprise Test Extension loaded"); diff --git a/server/e2e/test-extension-enterprise/extension.crx b/server/e2e/test-extension-enterprise/extension.crx new file mode 100644 index 00000000..9257e9af Binary files /dev/null and b/server/e2e/test-extension-enterprise/extension.crx differ diff --git a/server/e2e/test-extension-enterprise/manifest.json b/server/e2e/test-extension-enterprise/manifest.json new file mode 100644 index 00000000..286beabf --- /dev/null +++ b/server/e2e/test-extension-enterprise/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_version": 3, + "name": "Minimal Enterprise Test Extension", + "version": "1.0.0", + "description": "A minimal extension for testing enterprise policy installation", + "permissions": [ + "webRequest" + ], + "host_permissions": [ + "" + ], + "background": { + "service_worker": "background.js" + } +} diff --git a/server/e2e/test-extension-enterprise/pack.sh b/server/e2e/test-extension-enterprise/pack.sh new file mode 100755 index 00000000..7fef5684 --- /dev/null +++ b/server/e2e/test-extension-enterprise/pack.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Pack the test extension into a .crx file +# Usage: ./pack.sh +# +# Requires: google-chrome (or chromium), openssl, python3 +# +# This script: +# 1. Packs the extension using Chrome's built-in packer +# 2. Extracts and displays the extension ID from the private key +# +# The extension ID is derived from the public key, so as long as you use +# the same private_key.pem, you'll get the same extension ID. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Find Chrome binary +CHROME="" +for bin in google-chrome chromium chromium-browser; do + if command -v "$bin" &> /dev/null; then + CHROME="$bin" + break + fi +done + +if [ -z "$CHROME" ]; then + echo "Error: Chrome/Chromium not found" + exit 1 +fi + +# Check for private key +if [ ! -f "private_key.pem" ]; then + echo "Generating new private key..." + openssl genrsa -out private_key.pem 2048 +fi + +# Chrome won't pack if the key is inside the extension directory +mv private_key.pem /tmp/ext_key.pem +trap 'mv /tmp/ext_key.pem private_key.pem 2>/dev/null || true' EXIT + +# Pack the extension (Chrome creates .crx in parent directory) +echo "Packing extension..." +"$CHROME" --pack-extension="$SCRIPT_DIR" --pack-extension-key=/tmp/ext_key.pem --no-sandbox 2>&1 || true + +# Move the .crx file into place +PARENT_DIR="$(dirname "$SCRIPT_DIR")" +CRX_NAME="$(basename "$SCRIPT_DIR").crx" +if [ -f "$PARENT_DIR/$CRX_NAME" ]; then + mv "$PARENT_DIR/$CRX_NAME" extension.crx + echo "Created extension.crx" +else + echo "Error: Chrome did not create .crx file" + exit 1 +fi + +# Restore key before computing ID +mv /tmp/ext_key.pem private_key.pem +trap - EXIT + +# Extract extension ID from the public key +EXT_ID=$(python3 -c " +import hashlib +import subprocess +result = subprocess.run( + ['openssl', 'rsa', '-in', 'private_key.pem', '-pubout', '-outform', 'DER'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE +) +sha = hashlib.sha256(result.stdout).digest() +print(''.join(chr(ord('a') + (b >> 4)) + chr(ord('a') + (b & 0xf)) for b in sha[:16])) +") + +echo "Extension ID: $EXT_ID" +echo "" +echo "Make sure update.xml contains this appid:" +echo " " diff --git a/server/e2e/test-extension-enterprise/private_key.pem b/server/e2e/test-extension-enterprise/private_key.pem new file mode 100644 index 00000000..c7b1ad06 --- /dev/null +++ b/server/e2e/test-extension-enterprise/private_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCFq9yp0iW5AoUU +JwHEe+1KLEtjtOWLY2J4eZoFYKK29MZKK0xMw9vwv8WI1+RAbp37Ix0sEZnk9twG +8N78fpeCAYyyNTWVE6VSdA8Lyj9g5fd6b8NJlTCbptGEuqPPeACl9MDNb3BgOnla +cSCEz2UbZr5FC5eNFmOnPgFbmxoYRjBXGhkUhViFNr5sksyr/Aqi73AyTTRTgFsL +NySj4N1Z9SwbG4uIRP3mbChRknssHU9YfJ5Wg8PCf2BVOUI88peLVTm9JZAJ4eQO +FKJMWWFF1tWgVFyHFFiNSq5yhBBDTahEmsMfsxZDfwFq+9MIaQYe4GKiTNnohyQv +QwI2zE5FAgMBAAECggEAH45ghAiz1hbVstm4jZmR/aoyTAIHFJ5sPzO53B4hm55y +d0z8cLzmHdIwAGuzG1Dh48k42E/5bDeo3sybX7FE9DCIdgOguCZQp4P1j1t6FdaE +U3ex+xuIw54gpTxocpCKWqNKptTxiw4S2vqM6j2JyWu6bFNdvjV0ZiV5rZYSlXsZ +E+SWJD/DtI3sOPI145I91zcSWfNNVJgk3HJBaybKos+DKEdzJrlK4R1/XwCASvV2 ++Kj05eBrRw/vlwI8fRse7NrSbAsBsVMT+cBKloRJ5qoo8s+AJqS/yskLlxMh0F6G +/8FudUH9QXscafC9F10PbMLahl3i9XUn1Lp9ZAl2MQKBgQC5Gck+4D0qrRaLGufY +ohnljuRS9BqjVmMNFlh02JrcIUONikop7MbafQo8ThDrEC8wf0hcU4lP9bJcou4+ +KEgLPXkSu6WuXH7+H1q5lZvdG0eTmSzrmkULGmv2S8s3pSC9OYgCMi/X7HrAgCTU +1ndIDYpmMnhLDS/1oCO9NhnQbQKBgQC43x8yEbOCUUCxfRhnNumkUm8DUGvHmjMY +2U9FjNb/WZo7O16mDKF2qKzUEiKnwTnY0kW2qMk9hxH0AMJ5TJNAAAd3dziesxth +BnD3WjjnwKCnnbIJImfc4calayepB6uu92AwHhPijoH3v2Tx/6PpNSE24kaoXarn +FHLvaky+OQKBgD/L/2zIZMycs7RmJZqo5DwWr+NXdwbs817DYOGE9nsAjAPwsfcZ +QMB1cF2wmmwqO9l/RTVtJVqF2F/NWEfcIlida2lle4lJIAv+SorEYeAnUtgwZycd +GMbm6GcPYI9hPpN5jMMVASBuxTAr+oXRFXOkFxt6MbPMa8dA1pCUYPlRAoGARDhR +0rAlhdll/hkjgDMLVM/2l0p9+1IzuN+4GPo3/FKcT29BJhVTH+5umHN4xl7Pcetm +PllaqZHGVjxRh2FylRNtrfAYvLdrSwqNdTmd9idnIXNX13cSzLekDjbUk9N4z0AK +BreSru/XlgzSu1qSqeQpNY+ac1bdUhiBsUeQukECgYBYVAvNtNEo/vE36yBKou9n +h07oCVBRFfJK85EWo0VYfBNUXXmmZHqnWs7w6v9+NsoauwkD5F5PhcG/TvjLvvYg +U+d86c8h1byyKZD1bJMVSddL2LbHupSJ3OQslnqElpVQI2+1ZkNL2wDzr7++frla +ZCinC4sM9SYs3qM9yq2jQw== +-----END PRIVATE KEY----- diff --git a/server/e2e/test-extension-enterprise/update.xml b/server/e2e/test-extension-enterprise/update.xml new file mode 100644 index 00000000..35fdb7a3 --- /dev/null +++ b/server/e2e/test-extension-enterprise/update.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/server/lib/chromiumflags/chromiumflags.go b/server/lib/chromiumflags/chromiumflags.go index a9681656..6da8c595 100644 --- a/server/lib/chromiumflags/chromiumflags.go +++ b/server/lib/chromiumflags/chromiumflags.go @@ -119,7 +119,15 @@ func ReadOptionalFlagFile(path string) ([]string, error) { // The merging logic respects extension-related flag semantics: // 1) If runtime specifies --disable-extensions, it overrides everything extension related // 2) Else if base specifies --disable-extensions and runtime does NOT specify any --load-extension, keep base disable -// 3) Else, build from merged load/except +// 3) Else, build from merged load-extension paths +// +// NOTE: --disable-extensions-except is intentionally parsed but NOT re-emitted because it causes +// Chrome to disable external providers (including the policy loader), which prevents enterprise +// policy extensions (ExtensionInstallForcelist) from being fetched and installed. +// See Chromium source: extension_service.cc - external providers are only created when +// extensions_enabled() returns true, which is false when --disable-extensions-except is used. +// Any paths from --disable-extensions-except are merged into --load-extension instead. +// // Non-extension flags from both base and runtime are combined with deduplication (first occurrence preserved). func MergeFlags(baseTokens, runtimeTokens []string) []string { // Buckets @@ -127,9 +135,9 @@ func MergeFlags(baseTokens, runtimeTokens []string) []string { baseNonExt []string // Non-extension related flags contained in base runtimeNonExt []string // Non-extension related flags contained in runtime baseLoad []string // --load-extension flags contained in base - baseExcept []string // --disable-extensions-except flags for base + baseExcept []string // --disable-extensions-except flags for base (parsed but not re-emitted) rtLoad []string // --load-extension flags contained in runtime - rtExcept []string // --disable-extensions-except flags contained in runtime + rtExcept []string // --disable-extensions-except flags contained in runtime (parsed but not re-emitted) baseDisableAll string // --disable-extensions flag contained in base rtDisableAll string // --disable-extensions flag contained in runtime ) @@ -137,26 +145,26 @@ func MergeFlags(baseTokens, runtimeTokens []string) []string { baseNonExt = parseTokenStream(baseTokens, &baseLoad, &baseExcept, &baseDisableAll) runtimeNonExt = parseTokenStream(runtimeTokens, &rtLoad, &rtExcept, &rtDisableAll) - // Merge extension lists + // Merge extension lists - include paths from --disable-extensions-except in load paths + // since we no longer emit --disable-extensions-except mergedLoad := union(baseLoad, rtLoad) - mergedExcept := union(baseExcept, rtExcept) + mergedLoad = union(mergedLoad, baseExcept) + mergedLoad = union(mergedLoad, rtExcept) // Construct final extension-related flags respecting override semantics: // 1) If runtime specifies --disable-extensions, it overrides everything extension related // 2) Else if base specifies --disable-extensions and runtime does NOT specify any --load-extension, keep base disable - // 3) Else, build from merged load/except + // 3) Else, build from merged load-extension paths var extFlags []string if rtDisableAll != "" { extFlags = append(extFlags, rtDisableAll) } else { - if baseDisableAll != "" && len(rtLoad) == 0 { + if baseDisableAll != "" && len(rtLoad) == 0 && len(rtExcept) == 0 { extFlags = append(extFlags, baseDisableAll) } else if len(mergedLoad) > 0 { extFlags = append(extFlags, "--load-extension="+strings.Join(mergedLoad, ",")) } - if len(mergedExcept) > 0 { - extFlags = append(extFlags, "--disable-extensions-except="+strings.Join(mergedExcept, ",")) - } + // NOTE: --disable-extensions-except is intentionally NOT emitted here } // Combine and dedupe (preserving first occurrence) @@ -184,6 +192,38 @@ func MergeFlagsWithRuntimeTokens(baseFlags string, runtimeTokens []string) []str return MergeFlags(base, runtimeTokens) } +// MergeExtensionPath appends an extension path to existing --load-extension flags +// within an args slice. If the flag exists, the path is appended to its comma-separated +// list. If it doesn't exist, a new flag is added. This preserves other extensions that +// may already be configured. +// +// NOTE: We intentionally do NOT use --disable-extensions-except here because it causes +// Chrome to disable external providers (including the policy loader), which prevents +// enterprise policy extensions (ExtensionInstallForcelist) from being fetched and installed. +// See Chromium source: extension_service.cc - external providers are only created when +// extensions_enabled() returns true, which is false when --disable-extensions-except is used. +func MergeExtensionPath(args []string, extPath string) []string { + foundLoad := false + result := make([]string, 0, len(args)+1) + + for _, arg := range args { + switch { + case strings.HasPrefix(arg, "--load-extension="): + existing := strings.TrimPrefix(arg, "--load-extension=") + result = append(result, "--load-extension="+existing+","+extPath) + foundLoad = true + default: + result = append(result, arg) + } + } + + if !foundLoad { + result = append(result, "--load-extension="+extPath) + } + + return result +} + // WriteFlagFile writes the provided tokens to the given path as JSON in the // form: { "flags": ["--foo", "--bar=1"] } with file mode 0644. // The function creates or truncates the file. diff --git a/server/lib/chromiumflags/chromiumflags_test.go b/server/lib/chromiumflags/chromiumflags_test.go index 3063b98b..3900457a 100644 --- a/server/lib/chromiumflags/chromiumflags_test.go +++ b/server/lib/chromiumflags/chromiumflags_test.go @@ -103,44 +103,17 @@ func TestMergeUnion(t *testing.T) { func TestOverrideSemantics_DisableBase_LoadRuntime(t *testing.T) { // Base has --disable-extensions, runtime has --load-extension → runtime overrides, no disable-all in final - baseFlags := "--disable-extensions" - runtimeFlags := "--load-extension=/e1" + baseFlags := []string{"--disable-extensions"} + runtimeFlags := []string{"--load-extension=/e1"} - baseTokens := parseFlags(baseFlags) - runtimeTokens := parseFlags(runtimeFlags) - - var ( - baseLoad []string - baseExcept []string - rtLoad []string - rtExcept []string - baseDisable string - rtDisable string - ) - - _ = parseTokenStream(baseTokens, &baseLoad, &baseExcept, &baseDisable) - _ = parseTokenStream(runtimeTokens, &rtLoad, &rtExcept, &rtDisable) - - mergedLoad := union(baseLoad, rtLoad) - mergedExcept := union(baseExcept, rtExcept) - - var extFlags []string - if rtDisable != "" { - extFlags = append(extFlags, rtDisable) - } else { - if baseDisable != "" && len(rtLoad) == 0 { - extFlags = append(extFlags, baseDisable) - } else if len(mergedLoad) > 0 { - extFlags = append(extFlags, "--load-extension="+strings.Join(mergedLoad, ",")) - } - if len(mergedExcept) > 0 { - extFlags = append(extFlags, "--disable-extensions-except="+strings.Join(mergedExcept, ",")) - } - } + got := MergeFlags(baseFlags, runtimeFlags) - for _, f := range extFlags { + for _, f := range got { if f == "--disable-extensions" { - t.Fatalf("unexpected disable-all in final flags when runtime loads extensions: %#v", extFlags) + t.Fatalf("unexpected disable-all in final flags when runtime loads extensions: %#v", got) + } + if strings.HasPrefix(f, "--disable-extensions-except") { + t.Fatalf("unexpected disable-extensions-except in final flags: %#v", got) } } } @@ -285,10 +258,10 @@ func TestMergeFlags(t *testing.T) { want: []string{"--load-extension=/e1,/e2"}, }, { - name: "merge disable-extensions-except flags", + name: "disable-extensions-except paths merged into load-extension", baseFlags: []string{"--disable-extensions-except=/x1"}, runtimeFlags: []string{"--disable-extensions-except=/x2"}, - want: []string{"--disable-extensions-except=/x1,/x2"}, + want: []string{"--load-extension=/x1,/x2"}, }, { name: "runtime disable-extensions overrides all", @@ -312,7 +285,7 @@ func TestMergeFlags(t *testing.T) { name: "complex merge with extensions and non-extensions", baseFlags: []string{"--foo", "--load-extension=/e1", "--disable-extensions-except=/x1"}, runtimeFlags: []string{"--bar", "--load-extension=/e2", "--disable-extensions-except=/x2"}, - want: []string{"--foo", "--bar", "--load-extension=/e1,/e2", "--disable-extensions-except=/x1,/x2"}, + want: []string{"--foo", "--bar", "--load-extension=/e1,/e2,/x1,/x2"}, }, }