Skip to content

Commit 8695e35

Browse files
authored
[kernel 697] extension: support web bot auth (#108)
<!-- CURSOR_SUMMARY --> > [!NOTE] > Implements Chrome enterprise policy management, integrates it into extension uploads to force-install and allow hosts when required, adjusts container permissions, and adds an e2e test for web-bot-auth. > > - **Backend/API**: > - Integrates new `policy.Policy` into `ApiService` to manage Chrome enterprise policy at runtime. > - Extends `UploadExtensionsAndRestart` to: > - Detect if an extension needs enterprise policy via `manifest.json` (checks `webRequest`/`webRequestBlocking`). > - Update `/etc/chromium/policies/managed/policy.json` with `ExtensionSettings` (force-install and `runtime_allowed_hosts` when required). > - **Policy Library**: > - New `server/lib/policy` with read/modify/write helpers, `AddExtension`, `RequiresEnterprisePolicy`, and stable IDs via `GenerateExtensionID`. > - **Images**: > - `wrapper.sh` (headful/headless): chown `/etc/chromium/policies` to `kernel:kernel` for runtime policy updates. > - **Tests**: > - Adds e2e `TestWebBotAuthInstallation` to verify policy.json updates for a mock `web-bot-auth` extension. > - **Defaults**: > - Updates `shared/chromium-policies/managed/policy.json` to initialize `ExtensionSettings` with wildcard entry. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9597ef4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 9993a27 commit 8695e35

File tree

7 files changed

+328
-1
lines changed

7 files changed

+328
-1
lines changed

images/chromium-headful/wrapper.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ if [[ "${RUN_AS_ROOT:-}" != "true" ]]; then
6262

6363
# Ensure correct ownership (ignore errors if already correct)
6464
chown -R kernel:kernel /home/kernel /home/kernel/user-data /home/kernel/.config /home/kernel/.pki /home/kernel/.cache 2>/dev/null || true
65+
# Make policy directory writable for runtime updates
66+
chown -R kernel:kernel /etc/chromium/policies 2>/dev/null || true
6567
else
6668
# When running as root, just create the necessary directories without ownership changes
6769
dirs=(

images/chromium-headless/image/wrapper.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ if [[ "${RUN_AS_ROOT:-}" != "true" ]]; then
102102

103103
# Ensure correct ownership (ignore errors if already correct)
104104
chown -R kernel:kernel /home/kernel /home/kernel/user-data /home/kernel/.config /home/kernel/.pki /home/kernel/.cache 2>/dev/null || true
105+
# Make policy directory writable for runtime updates
106+
chown -R kernel:kernel /etc/chromium/policies 2>/dev/null || true
105107
else
106108
# When running as root, just create the necessary directories without ownership changes
107109
dirs=(

server/cmd/api/api/api.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/onkernel/kernel-images/server/lib/logger"
1313
"github.com/onkernel/kernel-images/server/lib/nekoclient"
1414
oapi "github.com/onkernel/kernel-images/server/lib/oapi"
15+
"github.com/onkernel/kernel-images/server/lib/policy"
1516
"github.com/onkernel/kernel-images/server/lib/recorder"
1617
"github.com/onkernel/kernel-images/server/lib/scaletozero"
1718
)
@@ -42,6 +43,9 @@ type ApiService struct {
4243

4344
// playwrightMu serializes Playwright code execution (only one execution at a time)
4445
playwrightMu sync.Mutex
46+
47+
// policy management
48+
policy *policy.Policy
4549
}
4650

4751
var _ oapi.StrictServerInterface = (*ApiService)(nil)
@@ -67,6 +71,7 @@ func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFa
6771
upstreamMgr: upstreamMgr,
6872
stz: stz,
6973
nekoAuthClient: nekoAuthClient,
74+
policy: &policy.Policy{},
7075
}, nil
7176
}
7277

server/cmd/api/api/chromium.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,36 @@ func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oap
160160
log.Info("installed extension", "name", p.name)
161161
}
162162

163+
// Update enterprise policy for extensions that require it
164+
for _, p := range items {
165+
extensionPath := filepath.Join(extBase, p.name)
166+
extensionID := s.policy.GenerateExtensionID(p.name)
167+
manifestPath := filepath.Join(extensionPath, "manifest.json")
168+
169+
// Check if this extension requires enterprise policy
170+
requiresEntPolicy, err := s.policy.RequiresEnterprisePolicy(manifestPath)
171+
if err != nil {
172+
log.Warn("failed to read manifest for policy check", "error", err, "extension", p.name)
173+
// Continue with requiresEntPolicy = false
174+
}
175+
176+
if requiresEntPolicy {
177+
log.Info("extension requires enterprise policy", "name", p.name)
178+
}
179+
180+
// Add to enterprise policy
181+
if err := s.policy.AddExtension(extensionID, extensionPath, requiresEntPolicy); err != nil {
182+
log.Error("failed to update enterprise policy", "error", err, "extension", p.name)
183+
return oapi.UploadExtensionsAndRestart500JSONResponse{
184+
InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{
185+
Message: fmt.Sprintf("failed to update enterprise policy for %s: %v", p.name, err),
186+
},
187+
}, nil
188+
}
189+
190+
log.Info("updated enterprise policy", "extension", p.name, "id", extensionID, "requiresEnterprisePolicy", requiresEntPolicy)
191+
}
192+
163193
// Build flags overlay file in /chromium/flags, merging with existing flags
164194
var paths []string
165195
for _, p := range items {

server/e2e/e2e_chromium_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,3 +884,115 @@ func listCDPTargets(ctx context.Context) ([]map[string]interface{}, error) {
884884

885885
return targets, nil
886886
}
887+
888+
func TestWebBotAuthInstallation(t *testing.T) {
889+
image := headlessImage
890+
name := containerName + "-web-bot-auth"
891+
892+
logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo}))
893+
baseCtx := logctx.AddToContext(context.Background(), logger)
894+
895+
if _, err := exec.LookPath("docker"); err != nil {
896+
require.NoError(t, err, "docker not available: %v", err)
897+
}
898+
899+
// Clean slate
900+
_ = stopContainer(baseCtx, name)
901+
902+
env := map[string]string{}
903+
904+
// Start container
905+
_, exitCh, err := runContainer(baseCtx, image, name, env)
906+
require.NoError(t, err, "failed to start container: %v", err)
907+
defer stopContainer(baseCtx, name)
908+
909+
ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute)
910+
defer cancel()
911+
912+
logger.Info("[setup]", "action", "waiting for API", "url", apiBaseURL+"/spec.yaml")
913+
require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err)
914+
915+
// Build mock web-bot-auth extension zip in-memory
916+
extDir := t.TempDir()
917+
manifest := `{
918+
"manifest_version": 3,
919+
"version": "1.0.0",
920+
"name": "Web Bot Auth Mock",
921+
"description": "Mock web-bot-auth extension for testing",
922+
"permissions": [
923+
"webRequest",
924+
"webRequestBlocking"
925+
],
926+
"host_permissions": [
927+
"*://*/*"
928+
]
929+
}`
930+
err = os.WriteFile(filepath.Join(extDir, "manifest.json"), []byte(manifest), 0600)
931+
require.NoError(t, err, "write manifest: %v", err)
932+
933+
extZip, err := zipDirToBytes(extDir)
934+
require.NoError(t, err, "zip ext: %v", err)
935+
936+
// Upload extension using the API
937+
{
938+
client, err := apiClient()
939+
require.NoError(t, err)
940+
var body bytes.Buffer
941+
w := multipart.NewWriter(&body)
942+
fw, err := w.CreateFormFile("extensions.zip_file", "web-bot-auth.zip")
943+
require.NoError(t, err)
944+
_, err = io.Copy(fw, bytes.NewReader(extZip))
945+
require.NoError(t, err)
946+
err = w.WriteField("extensions.name", "web-bot-auth")
947+
require.NoError(t, err)
948+
err = w.Close()
949+
require.NoError(t, err)
950+
951+
logger.Info("[test]", "action", "uploading web-bot-auth extension")
952+
start := time.Now()
953+
rsp, err := client.UploadExtensionsAndRestartWithBodyWithResponse(ctx, w.FormDataContentType(), &body)
954+
elapsed := time.Since(start)
955+
require.NoError(t, err, "uploadExtensionsAndRestart request error: %v", err)
956+
require.Equal(t, http.StatusCreated, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body))
957+
logger.Info("[test]", "action", "extension uploaded", "elapsed", elapsed.String())
958+
}
959+
960+
// Verify the policy.json file contains the correct web-bot-auth configuration
961+
{
962+
logger.Info("[test]", "action", "reading policy.json")
963+
policyContent, err := execCombinedOutput(ctx, "cat", []string{"/etc/chromium/policies/managed/policy.json"})
964+
require.NoError(t, err, "failed to read policy.json: %v", err)
965+
966+
logger.Info("[test]", "policy_content", policyContent)
967+
968+
var policy map[string]interface{}
969+
err = json.Unmarshal([]byte(policyContent), &policy)
970+
require.NoError(t, err, "failed to parse policy.json: %v", err)
971+
972+
// Check ExtensionSettings exists
973+
extensionSettings, ok := policy["ExtensionSettings"].(map[string]interface{})
974+
require.True(t, ok, "ExtensionSettings not found in policy.json")
975+
976+
// Check web-bot-auth entry exists
977+
webBotAuth, ok := extensionSettings["web-bot-auth"].(map[string]interface{})
978+
require.True(t, ok, "web-bot-auth entry not found in ExtensionSettings")
979+
980+
// Verify installation_mode is force_installed
981+
installationMode, ok := webBotAuth["installation_mode"].(string)
982+
require.True(t, ok, "installation_mode not found in web-bot-auth entry")
983+
require.Equal(t, "force_installed", installationMode, "expected installation_mode to be force_installed")
984+
985+
// Verify path
986+
path, ok := webBotAuth["path"].(string)
987+
require.True(t, ok, "path not found in web-bot-auth entry")
988+
require.Equal(t, "/home/kernel/extensions/web-bot-auth", path, "expected path to be /home/kernel/extensions/web-bot-auth")
989+
990+
// Verify runtime_allowed_hosts
991+
runtimeAllowedHosts, ok := webBotAuth["runtime_allowed_hosts"].([]interface{})
992+
require.True(t, ok, "runtime_allowed_hosts not found in web-bot-auth entry")
993+
require.Len(t, runtimeAllowedHosts, 1, "expected runtime_allowed_hosts to have 1 entry")
994+
require.Equal(t, "*://*/*", runtimeAllowedHosts[0].(string), "expected runtime_allowed_hosts to contain *://*/*")
995+
996+
logger.Info("[test]", "result", "web-bot-auth policy verified successfully")
997+
}
998+
}

server/lib/policy/policy.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package policy
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"sync"
8+
)
9+
10+
const PolicyPath = "/etc/chromium/policies/managed/policy.json"
11+
12+
// Policy represents the Chrome enterprise policy structure
13+
type Policy struct {
14+
mu sync.Mutex
15+
16+
PasswordManagerEnabled bool `json:"PasswordManagerEnabled"`
17+
AutofillCreditCardEnabled bool `json:"AutofillCreditCardEnabled"`
18+
TranslateEnabled bool `json:"TranslateEnabled"`
19+
DefaultNotificationsSetting int `json:"DefaultNotificationsSetting"`
20+
ExtensionSettings map[string]ExtensionSetting `json:"ExtensionSettings"`
21+
}
22+
23+
// ExtensionSetting represents settings for a specific extension
24+
type ExtensionSetting struct {
25+
InstallationMode string `json:"installation_mode,omitempty"`
26+
Path string `json:"path,omitempty"`
27+
AllowedTypes []string `json:"allowed_types,omitempty"`
28+
InstallSources []string `json:"install_sources,omitempty"`
29+
RuntimeBlockedHosts []string `json:"runtime_blocked_hosts,omitempty"`
30+
RuntimeAllowedHosts []string `json:"runtime_allowed_hosts,omitempty"`
31+
}
32+
33+
// readPolicyUnlocked reads the current enterprise policy from disk without locking
34+
// This is an internal helper for use within already-locked operations
35+
func (p *Policy) readPolicyUnlocked() (*Policy, error) {
36+
data, err := os.ReadFile(PolicyPath)
37+
if err != nil {
38+
if os.IsNotExist(err) {
39+
// Return default policy if file doesn't exist
40+
return &Policy{
41+
PasswordManagerEnabled: false,
42+
AutofillCreditCardEnabled: false,
43+
TranslateEnabled: false,
44+
DefaultNotificationsSetting: 2,
45+
ExtensionSettings: make(map[string]ExtensionSetting),
46+
}, nil
47+
}
48+
return nil, fmt.Errorf("failed to read policy file: %w", err)
49+
}
50+
51+
var policy Policy
52+
if err := json.Unmarshal(data, &policy); err != nil {
53+
return nil, fmt.Errorf("failed to parse policy file: %w", err)
54+
}
55+
56+
// Initialize ExtensionSettings map if it's nil to prevent panic on write
57+
if policy.ExtensionSettings == nil {
58+
policy.ExtensionSettings = make(map[string]ExtensionSetting)
59+
}
60+
61+
return &policy, nil
62+
}
63+
64+
// ReadPolicy reads the current enterprise policy from disk
65+
func (p *Policy) ReadPolicy() (*Policy, error) {
66+
p.mu.Lock()
67+
defer p.mu.Unlock()
68+
69+
return p.readPolicyUnlocked()
70+
}
71+
72+
// writePolicyUnlocked writes the policy to disk without locking
73+
// This is an internal helper for use within already-locked operations
74+
func (p *Policy) writePolicyUnlocked(policy *Policy) error {
75+
data, err := json.MarshalIndent(policy, "", " ")
76+
if err != nil {
77+
return fmt.Errorf("failed to marshal policy: %w", err)
78+
}
79+
80+
if err := os.WriteFile(PolicyPath, data, 0644); err != nil {
81+
return fmt.Errorf("failed to write policy file: %w", err)
82+
}
83+
84+
return nil
85+
}
86+
87+
// WritePolicy writes the policy to disk
88+
func (p *Policy) WritePolicy(policy *Policy) error {
89+
p.mu.Lock()
90+
defer p.mu.Unlock()
91+
92+
return p.writePolicyUnlocked(policy)
93+
}
94+
95+
// AddExtension adds or updates an extension in the policy
96+
// extensionID should be a stable identifier (can be derived from extension path)
97+
func (p *Policy) AddExtension(extensionID, extensionPath string, requiresEnterprisePolicy bool) error {
98+
// Lock for the entire read-modify-write cycle to prevent race conditions
99+
p.mu.Lock()
100+
defer p.mu.Unlock()
101+
102+
policy, err := p.readPolicyUnlocked()
103+
if err != nil {
104+
return err
105+
}
106+
107+
// Ensure the wildcard policy exists
108+
if _, exists := policy.ExtensionSettings["*"]; !exists {
109+
policy.ExtensionSettings["*"] = ExtensionSetting{
110+
AllowedTypes: []string{"extension"},
111+
InstallSources: []string{"*"},
112+
}
113+
}
114+
115+
// Add the specific extension
116+
setting := ExtensionSetting{
117+
Path: extensionPath,
118+
}
119+
120+
// If the extension requires enterprise policy (like webRequestBlocking),
121+
// set it as force_installed https://github.com/cloudflare/web-bot-auth/blob/main/examples/browser-extension/policy/policy.json.templ
122+
if requiresEnterprisePolicy {
123+
setting.InstallationMode = "force_installed"
124+
// Allow all hosts for webRequest APIs
125+
setting.RuntimeAllowedHosts = []string{"*://*/*"}
126+
} else {
127+
setting.InstallationMode = "normal_installed"
128+
}
129+
130+
policy.ExtensionSettings[extensionID] = setting
131+
132+
return p.writePolicyUnlocked(policy)
133+
}
134+
135+
// GenerateExtensionID returns a stable identifier for the extension policy.
136+
// For ExtensionSettings with local paths, Chrome allows custom identifiers.
137+
// We use the extension name because it's stable, readable, and matches the directory.
138+
func (p *Policy) GenerateExtensionID(extensionName string) string {
139+
return extensionName
140+
}
141+
142+
// RequiresEnterprisePolicy checks if an extension requires enterprise policy
143+
// by examining its manifest.json for webRequestBlocking or webRequest permissions
144+
func (p *Policy) RequiresEnterprisePolicy(manifestPath string) (bool, error) {
145+
manifestData, err := os.ReadFile(manifestPath)
146+
if err != nil {
147+
return false, err
148+
}
149+
150+
var manifest map[string]interface{}
151+
if err := json.Unmarshal(manifestData, &manifest); err != nil {
152+
return false, err
153+
}
154+
155+
// Check if permissions include webRequestBlocking or webRequest
156+
perms, ok := manifest["permissions"].([]interface{})
157+
if !ok {
158+
return false, nil
159+
}
160+
161+
for _, perm := range perms {
162+
if permStr, ok := perm.(string); ok {
163+
if permStr == "webRequestBlocking" || permStr == "webRequest" {
164+
return true, nil
165+
}
166+
}
167+
}
168+
169+
return false, nil
170+
}

shared/chromium-policies/managed/policy.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,11 @@
22
"PasswordManagerEnabled": false,
33
"AutofillCreditCardEnabled": false,
44
"TranslateEnabled": false,
5-
"DefaultNotificationsSetting": 2
5+
"DefaultNotificationsSetting": 2,
6+
"ExtensionSettings": {
7+
"*": {
8+
"allowed_types": ["extension"],
9+
"install_sources": ["*"]
10+
}
11+
}
612
}

0 commit comments

Comments
 (0)