Skip to content
Merged
2 changes: 2 additions & 0 deletions images/chromium-headful/wrapper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ if [[ "${RUN_AS_ROOT:-}" != "true" ]]; then

# Ensure correct ownership (ignore errors if already correct)
chown -R kernel:kernel /home/kernel /home/kernel/user-data /home/kernel/.config /home/kernel/.pki /home/kernel/.cache 2>/dev/null || true
# Make policy directory writable for runtime updates
chown -R kernel:kernel /etc/chromium/policies 2>/dev/null || true
else
# When running as root, just create the necessary directories without ownership changes
dirs=(
Expand Down
2 changes: 2 additions & 0 deletions images/chromium-headless/image/wrapper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ if [[ "${RUN_AS_ROOT:-}" != "true" ]]; then

# Ensure correct ownership (ignore errors if already correct)
chown -R kernel:kernel /home/kernel /home/kernel/user-data /home/kernel/.config /home/kernel/.pki /home/kernel/.cache 2>/dev/null || true
# Make policy directory writable for runtime updates
chown -R kernel:kernel /etc/chromium/policies 2>/dev/null || true
else
# When running as root, just create the necessary directories without ownership changes
dirs=(
Expand Down
5 changes: 5 additions & 0 deletions server/cmd/api/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/onkernel/kernel-images/server/lib/logger"
"github.com/onkernel/kernel-images/server/lib/nekoclient"
oapi "github.com/onkernel/kernel-images/server/lib/oapi"
"github.com/onkernel/kernel-images/server/lib/policy"
"github.com/onkernel/kernel-images/server/lib/recorder"
"github.com/onkernel/kernel-images/server/lib/scaletozero"
)
Expand Down Expand Up @@ -42,6 +43,9 @@ type ApiService struct {

// playwrightMu serializes Playwright code execution (only one execution at a time)
playwrightMu sync.Mutex

// policy management
policy *policy.Policy
}

var _ oapi.StrictServerInterface = (*ApiService)(nil)
Expand All @@ -67,6 +71,7 @@ func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFa
upstreamMgr: upstreamMgr,
stz: stz,
nekoAuthClient: nekoAuthClient,
policy: &policy.Policy{},
}, nil
}

Expand Down
30 changes: 30 additions & 0 deletions server/cmd/api/api/chromium.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,36 @@ func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oap
log.Info("installed extension", "name", p.name)
}

// Update enterprise policy for extensions that require it
for _, p := range items {
extensionPath := filepath.Join(extBase, p.name)
extensionID := s.policy.GenerateExtensionID(p.name)
manifestPath := filepath.Join(extensionPath, "manifest.json")

// 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)
// Continue with requiresEntPolicy = false
}

if requiresEntPolicy {
log.Info("extension requires enterprise policy", "name", p.name)
}

// 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)
return oapi.UploadExtensionsAndRestart500JSONResponse{
InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{
Message: fmt.Sprintf("failed to update enterprise policy for %s: %v", p.name, err),
},
}, nil
}

log.Info("updated enterprise policy", "extension", p.name, "id", extensionID, "requiresEnterprisePolicy", requiresEntPolicy)
}

// Build flags overlay file in /chromium/flags, merging with existing flags
var paths []string
for _, p := range items {
Expand Down
112 changes: 112 additions & 0 deletions server/e2e/e2e_chromium_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -884,3 +884,115 @@ func listCDPTargets(ctx context.Context) ([]map[string]interface{}, error) {

return targets, nil
}

func TestWebBotAuthInstallation(t *testing.T) {
image := headlessImage
name := containerName + "-web-bot-auth"

logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo}))
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)

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, 3*time.Minute)
defer cancel()

logger.Info("[setup]", "action", "waiting for API", "url", apiBaseURL+"/spec.yaml")
require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err)

// 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)
require.NoError(t, err, "write manifest: %v", err)

extZip, err := zipDirToBytes(extDir)
require.NoError(t, err, "zip ext: %v", err)

// Upload extension using the API
{
client, err := apiClient()
require.NoError(t, err)
var body bytes.Buffer
w := multipart.NewWriter(&body)
fw, err := w.CreateFormFile("extensions.zip_file", "web-bot-auth.zip")
require.NoError(t, err)
_, err = io.Copy(fw, bytes.NewReader(extZip))
require.NoError(t, err)
err = w.WriteField("extensions.name", "web-bot-auth")
require.NoError(t, err)
err = w.Close()
require.NoError(t, err)

logger.Info("[test]", "action", "uploading web-bot-auth extension")
start := time.Now()
rsp, err := client.UploadExtensionsAndRestartWithBodyWithResponse(ctx, w.FormDataContentType(), &body)
elapsed := time.Since(start)
require.NoError(t, err, "uploadExtensionsAndRestart request error: %v", err)
require.Equal(t, http.StatusCreated, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body))
logger.Info("[test]", "action", "extension uploaded", "elapsed", elapsed.String())
}

// Verify the policy.json file contains the correct web-bot-auth configuration
{
logger.Info("[test]", "action", "reading policy.json")
policyContent, err := execCombinedOutput(ctx, "cat", []string{"/etc/chromium/policies/managed/policy.json"})
require.NoError(t, err, "failed to read policy.json: %v", err)

logger.Info("[test]", "policy_content", policyContent)

var policy map[string]interface{}
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 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")

// 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 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 *://*/*")

logger.Info("[test]", "result", "web-bot-auth policy verified successfully")
}
}
170 changes: 170 additions & 0 deletions server/lib/policy/policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package policy

import (
"encoding/json"
"fmt"
"os"
"sync"
)

const PolicyPath = "/etc/chromium/policies/managed/policy.json"

// Policy represents the Chrome enterprise policy structure
type Policy struct {
mu sync.Mutex

PasswordManagerEnabled bool `json:"PasswordManagerEnabled"`
AutofillCreditCardEnabled bool `json:"AutofillCreditCardEnabled"`
TranslateEnabled bool `json:"TranslateEnabled"`
DefaultNotificationsSetting int `json:"DefaultNotificationsSetting"`
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"`
AllowedTypes []string `json:"allowed_types,omitempty"`
InstallSources []string `json:"install_sources,omitempty"`
RuntimeBlockedHosts []string `json:"runtime_blocked_hosts,omitempty"`
RuntimeAllowedHosts []string `json:"runtime_allowed_hosts,omitempty"`
}

// readPolicyUnlocked reads the current enterprise policy from disk without locking
// This is an internal helper for use within already-locked operations
func (p *Policy) readPolicyUnlocked() (*Policy, error) {
data, err := os.ReadFile(PolicyPath)
if err != nil {
if os.IsNotExist(err) {
// Return default policy if file doesn't exist
return &Policy{
PasswordManagerEnabled: false,
AutofillCreditCardEnabled: false,
TranslateEnabled: false,
DefaultNotificationsSetting: 2,
ExtensionSettings: make(map[string]ExtensionSetting),
}, nil
}
return nil, fmt.Errorf("failed to read policy file: %w", err)
}

var policy Policy
if err := json.Unmarshal(data, &policy); err != nil {
return nil, fmt.Errorf("failed to parse policy file: %w", err)
}

// Initialize ExtensionSettings map if it's nil to prevent panic on write
if policy.ExtensionSettings == nil {
policy.ExtensionSettings = make(map[string]ExtensionSetting)
}

return &policy, nil
}

// ReadPolicy reads the current enterprise policy from disk
func (p *Policy) ReadPolicy() (*Policy, error) {
p.mu.Lock()
defer p.mu.Unlock()

return p.readPolicyUnlocked()
}

// writePolicyUnlocked writes the policy to disk without locking
// This is an internal helper for use within already-locked operations
func (p *Policy) writePolicyUnlocked(policy *Policy) error {
data, err := json.MarshalIndent(policy, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal policy: %w", err)
}

if err := os.WriteFile(PolicyPath, data, 0644); err != nil {
return fmt.Errorf("failed to write policy file: %w", err)
}

return nil
}

// WritePolicy writes the policy to disk
func (p *Policy) WritePolicy(policy *Policy) error {
p.mu.Lock()
defer p.mu.Unlock()

return p.writePolicyUnlocked(policy)
}

// 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 {
// Lock for the entire read-modify-write cycle to prevent race conditions
p.mu.Lock()
defer p.mu.Unlock()

policy, err := p.readPolicyUnlocked()
if err != nil {
return err
}

// Ensure the wildcard policy exists
if _, exists := policy.ExtensionSettings["*"]; !exists {
policy.ExtensionSettings["*"] = ExtensionSetting{
AllowedTypes: []string{"extension"},
InstallSources: []string{"*"},
}
}

// Add the specific extension
setting := ExtensionSetting{
Path: extensionPath,
}

// 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
if requiresEnterprisePolicy {
setting.InstallationMode = "force_installed"
// Allow all hosts for webRequest APIs
setting.RuntimeAllowedHosts = []string{"*://*/*"}
} else {
setting.InstallationMode = "normal_installed"
}

policy.ExtensionSettings[extensionID] = setting

return p.writePolicyUnlocked(policy)
}

// GenerateExtensionID returns a stable identifier for the extension policy.
// For ExtensionSettings with local paths, Chrome allows custom identifiers.
// We use the extension name because it's stable, readable, and matches the directory.
func (p *Policy) GenerateExtensionID(extensionName string) string {
return extensionName
}

// RequiresEnterprisePolicy checks if an extension requires enterprise policy
// by examining its manifest.json for webRequestBlocking or webRequest permissions
func (p *Policy) RequiresEnterprisePolicy(manifestPath string) (bool, error) {
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
return false, err
}

var manifest map[string]interface{}
if err := json.Unmarshal(manifestData, &manifest); err != nil {
return false, err
}

// Check if permissions include webRequestBlocking or webRequest
perms, ok := manifest["permissions"].([]interface{})
if !ok {
return false, nil
}

for _, perm := range perms {
if permStr, ok := perm.(string); ok {
if permStr == "webRequestBlocking" || permStr == "webRequest" {
return true, nil
}
}
}

return false, nil
}
8 changes: 7 additions & 1 deletion shared/chromium-policies/managed/policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,11 @@
"PasswordManagerEnabled": false,
"AutofillCreditCardEnabled": false,
"TranslateEnabled": false,
"DefaultNotificationsSetting": 2
"DefaultNotificationsSetting": 2,
"ExtensionSettings": {
"*": {
"allowed_types": ["extension"],
"install_sources": ["*"]
}
}
}
Loading