diff --git a/docs/adr/0106-full-cdp-debug-channel.md b/docs/adr/0106-full-cdp-debug-channel.md new file mode 100644 index 00000000..e7ee0b26 --- /dev/null +++ b/docs/adr/0106-full-cdp-debug-channel.md @@ -0,0 +1,45 @@ +# ADR 0106: Full CDP Debug Channel + +Status: accepted + +Date: 2026-08-16 + +## Context + +Safe Web and Full CDP are two independent browser permission chains. Safe Web +is the restricted, network-contained operator entry (ADR 0105); Full CDP is the +highly-sensitive debug channel that can observe, capture, mutate, and replay +requests and access cookies. Full CDP must never inherit Safe Web's evidence or +authorization, must never disable browser security, and must be bound to a +short-lived, per-call capability. + +## Decision + +`FullCDPAuthorization` (`browser_full_cdp_authorization.v1`) is independent from +the Safe Web start authorization. It is issued only when the Run uses the +maximum-access debug permission (with operator confirmation), the process +enabled the full-debug gate and the restricted-CDP runtime, and the caller +supplies an exact per-call confirmation. It binds the Run, Workspace, accepted +executable identity, permission revision, and session scope, and expires after a +five-minute TTL. It authorizes request capture, mutation, replay, cookie access, +and arbitrary methods, but never webpage-instruction elevation +(`InstructionAuthorized` is always false) and never origin-policy or certificate +relaxation. + +The `FullCDPSession` reuses the restricted CDP WebSocket client but admits an +additional highly-sensitive method set (`Network.getCookies`, +`Network.getAllCookies`, `Storage.getCookies`, `Fetch.fulfillRequest`, +`Runtime.*`, `Log.enable`) only when the client carries the Full CDP flag. Every +result is metadata-only: raw request bodies, headers, cookie values, and page +content are never returned. + +## Consequences + +Full CDP and Safe Web are provably non-crossing: Safe Web evidence or the +restricted permission cannot authorize Full CDP, and the maximum-access debug +permission cannot enter the restricted runtime. The debug capability is +TTL-bounded, per-call confirmed, and invalid after restart. The metadata-only +result contract removes secret/cookie/header/body leakage by construction. The +dedicated, one-shot browser process and Profile remain shared with the Safe Web +launch machinery, while the Full CDP authorization stays a separate, bounded +gate. diff --git a/internal/application/full_cdp_service.go b/internal/application/full_cdp_service.go new file mode 100644 index 00000000..bfa071ac --- /dev/null +++ b/internal/application/full_cdp_service.go @@ -0,0 +1,115 @@ +package application + +import ( + "context" + "errors" + "strings" + "time" + + "cyberagent-workbench/internal/browserruntime" + "cyberagent-workbench/internal/domain" +) + +// FullCDPStore supplies the durable Run, Mission, and browser CDP permission +// facts the Full CDP operator flow consumes. +type FullCDPStore interface { + GetRun(ctx context.Context, id string) (domain.Run, error) + GetMission(ctx context.Context, id string) (domain.Mission, error) + GetRunBrowserCDPPermission(ctx context.Context, + runID string) (domain.RunBrowserCDPPermissionSnapshot, error) +} + +// FullCDPService opens the highly-sensitive Full CDP debug channel. It is +// independent from Safe Web: it requires the maximum-access debug permission, +// an exact per-call confirmation, and a dedicated, contained browser process, +// and it never inherits Safe Web evidence or authorization. +type FullCDPService struct { + store FullCDPStore + runtimeCapabilities browserruntime.ProductionRuntimeCapabilities + permissionCapabilities domain.BrowserCDPPermissionRuntimeCapabilities +} + +func NewFullCDPService(store FullCDPStore, + runtimeCapabilities browserruntime.ProductionRuntimeCapabilities, + permissionCapabilities domain.BrowserCDPPermissionRuntimeCapabilities, +) *FullCDPService { + return &FullCDPService{store: store, runtimeCapabilities: runtimeCapabilities, + permissionCapabilities: permissionCapabilities} +} + +// FullCDPOpenRequest is a bounded request to open the Full CDP debug session +// over an already launched, contained browser process. +type FullCDPOpenRequest struct { + RunID string + Target string + Identity browserruntime.BrowserExecutableIdentity + Acceptance browserruntime.BrowserAcceptanceCandidate + Ownership browserruntime.ProfileOwnershipPlan + Confirmed bool + ProfileLease browserruntime.ProfileRuntimeLease + Process *browserruntime.BrowserProcess +} + +// Open authorizes and dials the Full CDP session. It fails closed unless the +// Run uses the maximum-access debug permission, the caller supplies an exact +// per-call confirmation, and the dedicated browser process is live. +func (s *FullCDPService) Open(ctx context.Context, + request FullCDPOpenRequest, +) (*browserruntime.FullCDPSession, error) { + if s == nil || s.store == nil { + return nil, errors.New("full CDP service is not fully configured") + } + if err := s.runtimeCapabilities.Validate(); err != nil { + return nil, err + } + if err := s.permissionCapabilities.Validate(); err != nil { + return nil, err + } + if err := browserruntime.ValidateBrowserExecutableIdentity(request.Identity); err != nil { + return nil, err + } + if err := browserruntime.ValidateBrowserAcceptanceCandidate( + request.Acceptance, request.Identity); err != nil { + return nil, err + } + request.RunID = strings.TrimSpace(request.RunID) + if request.RunID == "" || !domain.ValidAgentID(request.RunID) { + return nil, errors.New("full CDP Run id is invalid") + } + run, err := s.store.GetRun(ctx, request.RunID) + if err != nil { + return nil, err + } + if !domain.CanChangeRunBrowserCDPPermission(run.Status) { + return nil, errors.New("full CDP requires a non-terminal Run") + } + mission, err := s.store.GetMission(ctx, run.MissionID) + if err != nil { + return nil, err + } + permission, err := s.store.GetRunBrowserCDPPermission(ctx, request.RunID) + if err != nil { + return nil, err + } + now := time.Now().UTC() + session, err := browserruntime.BuildSessionPlan(browserruntime.NewSessionPlanRequest{ + SessionID: run.SessionID, RunID: run.ID, WorkspaceID: mission.WorkspaceID, + ProfileID: browserruntime.ProfileCTFLab, Targets: []string{request.Target}, + Features: browserruntime.FeatureRequest{ + InterceptRequests: true, ModifyRequests: true, + ReplayRequests: true, EditCookies: true, + }, + }) + if err != nil { + return nil, err + } + authorization, err := browserruntime.AuthorizeFullCDP(session, request.Identity, + request.Acceptance, permission, s.runtimeCapabilities, + s.permissionCapabilities, request.Confirmed, now) + if err != nil { + return nil, err + } + return browserruntime.OpenFullCDPSession(ctx, authorization, session, + request.Identity, request.Acceptance, request.Ownership, + permission, request.ProfileLease, request.Process) +} diff --git a/internal/application/full_cdp_service_test.go b/internal/application/full_cdp_service_test.go new file mode 100644 index 00000000..fa333e42 --- /dev/null +++ b/internal/application/full_cdp_service_test.go @@ -0,0 +1,143 @@ +package application + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "cyberagent-workbench/internal/browserruntime" + "cyberagent-workbench/internal/domain" +) + +type fakeFullCDPStore struct { + run domain.Run + mission domain.Mission + permission domain.RunBrowserCDPPermissionSnapshot +} + +func (f *fakeFullCDPStore) GetRun(context.Context, string) (domain.Run, error) { + return f.run, nil +} + +func (f *fakeFullCDPStore) GetMission(context.Context, string) (domain.Mission, error) { + return f.mission, nil +} + +func (f *fakeFullCDPStore) GetRunBrowserCDPPermission(context.Context, + string, +) (domain.RunBrowserCDPPermissionSnapshot, error) { + return f.permission, nil +} + +func fullCDPIdentityAcceptance(t *testing.T) (browserruntime.BrowserExecutableIdentity, + browserruntime.BrowserAcceptanceCandidate, +) { + t.Helper() + relative := filepath.ToSlash(filepath.Join("Google", "Chrome", "Application", "chrome.exe")) + identity := browserruntime.BrowserExecutableIdentity{ + ProtocolVersion: browserruntime.BrowserExecutableIdentityProtocolVersion, + Product: browserruntime.BrowserProductChrome, Channel: browserruntime.BrowserChannelStable, + Vendor: "Google", RootID: browserruntime.DiscoveryRootProgramFiles, + CanonicalPath: filepath.Join(t.TempDir(), filepath.FromSlash(relative)), + RelativePath: relative, HostGOOS: runtime.GOOS, HostGOARCH: runtime.GOARCH, + TargetGOARCH: "amd64", ExecutableBytes: 1024, + ExecutableSHA256: strings.Repeat("a", 64), + VersionSource: browserruntime.VersionSourceUnavailable, + PEFormatVerified: true, RegularFileVerified: true, SymlinkRejected: true, + MetadataOnly: true, + } + identity.Fingerprint = fullCDPFingerprint(t, identity) + if err := browserruntime.ValidateBrowserExecutableIdentity(identity); err != nil { + t.Fatal(err) + } + acceptance := browserruntime.BrowserAcceptanceCandidate{ + ProtocolVersion: browserruntime.BrowserAcceptanceProtocolVersion, + ExecutableIdentityFingerprint: identity.Fingerprint, + Product: identity.Product, Channel: identity.Channel, RootID: identity.RootID, + ExecutableSHA256: identity.ExecutableSHA256, ExecutableBytes: identity.ExecutableBytes, + TargetGOARCH: identity.TargetGOARCH, + Decision: browserruntime.BrowserAcceptanceAccepted, + ReasonCode: browserruntime.BrowserAcceptanceReasonPublisherVerified, + Evidence: browserruntime.AuthenticodeEvidence{ + Source: browserruntime.AuthenticodeSourceWindows, Publisher: "Google LLC", + CertificateSHA256: strings.Repeat("b", 64), SignatureVerified: true, + SameOpenHandleVerified: true, CacheOnlyVerification: true, + PublisherPolicyMatched: true, + PublisherPolicyVersion: browserruntime.BrowserPublisherPolicyVersion, + PublisherEvidenceComplete: true, + }, + SameHandleBytesRevalidated: true, SameFilePathRevalidated: true, + PERevalidated: true, ReviewEligible: true, StartBlocked: true, MetadataOnly: true, + } + acceptance.Fingerprint = fullCDPFingerprint(t, acceptance) + if err := browserruntime.ValidateBrowserAcceptanceCandidate(acceptance, identity); err != nil { + t.Fatal(err) + } + return identity, acceptance +} + +func fullCDPFingerprint(t *testing.T, value any) string { + t.Helper() + switch typed := value.(type) { + case browserruntime.BrowserExecutableIdentity: + typed.Fingerprint = "" + value = typed + case browserruntime.BrowserAcceptanceCandidate: + typed.Fingerprint = "" + value = typed + default: + t.Fatalf("unsupported full CDP fixture fingerprint type %T", value) + } + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(raw) + return hex.EncodeToString(digest[:]) +} + +func TestFullCDPServiceRefusesWithoutConfirmation(t *testing.T) { + now := time.Now().UTC().Round(time.Millisecond) + identity, acceptance := fullCDPIdentityAcceptance(t) + permission, err := domain.NewInitialRunBrowserCDPPermissionSnapshot( + "browser-cdp-full-service", domain.Run{ID: "run-full-service", + MissionID: "mission-full-service", Status: domain.RunCreated, CreatedAt: now}, + domain.Mission{ID: "mission-full-service", CreatedAt: now}, + "runtime-operator", now) + if err != nil { + t.Fatal(err) + } + full, err := permission.Next("browser-cdp-full-debug", + domain.RunBrowserCDPPermissionFullDebug, true, "runtime-operator", + "full debug", now) + if err != nil { + t.Fatal(err) + } + store := &fakeFullCDPStore{ + run: domain.Run{ID: "run-full-service", MissionID: "mission-full-service", + SessionID: "session-full-service", Status: domain.RunCreated, CreatedAt: now}, + mission: domain.Mission{ID: "mission-full-service", + WorkspaceID: "workspace-full-service", CreatedAt: now}, + permission: full, + } + service := NewFullCDPService(store, + browserruntime.ProductionRuntimeCapabilities{ + SafeWebStartEnabled: true, DisposableProfileEnabled: true, + NetworkContainmentEnabled: true, RestrictedCDPEnabled: true, + }, + domain.BrowserCDPPermissionRuntimeCapabilities{ControlEnabled: true, + FullDebugEnabled: true}) + _, err = service.Open(t.Context(), FullCDPOpenRequest{ + RunID: "run-full-service", Target: "http://127.0.0.1:18080", + Identity: identity, Acceptance: acceptance, + }) + if err == nil { + t.Fatal("unconfirmed full CDP unexpectedly opened a session") + } +} diff --git a/internal/browserruntime/full_cdp_authorization.go b/internal/browserruntime/full_cdp_authorization.go new file mode 100644 index 00000000..88e10d9f --- /dev/null +++ b/internal/browserruntime/full_cdp_authorization.go @@ -0,0 +1,179 @@ +package browserruntime + +import ( + "errors" + "fmt" + "net/netip" + "time" + + "cyberagent-workbench/internal/domain" +) + +const ( + FullCDPAuthorizationProtocolVersion = "browser_full_cdp_authorization.v1" + FullCDPCapabilityTTL = 5 * time.Minute +) + +// FullCDPAuthorization is a short-lived, per-call, highly-sensitive CDP +// authorization. It is fully independent from the Safe Web restricted +// authorization: Safe Web evidence or authorization never grants Full CDP, and +// Full CDP never inherits the Safe Web boundary. It binds the Run, Workspace, +// accepted browser executable, permission revision, and session scope. +type FullCDPAuthorization struct { + ProtocolVersion string `json:"protocol_version"` + RunID string `json:"run_id"` + WorkspaceID string `json:"workspace_id"` + ExecutableIdentityFingerprint string `json:"executable_identity_fingerprint"` + PermissionSnapshotID string `json:"permission_snapshot_id"` + PermissionRevision int64 `json:"permission_revision"` + ScopeFingerprint string `json:"scope_fingerprint"` + NavigateAuthorized bool `json:"navigate_authorized"` + DOMMetadataAuthorized bool `json:"dom_metadata_authorized"` + ScreenshotAuthorized bool `json:"screenshot_authorized"` + RequestCaptureAuthorized bool `json:"request_capture_authorized"` + RequestMutationAuthorized bool `json:"request_mutation_authorized"` + RequestReplayAuthorized bool `json:"request_replay_authorized"` + CookieAccessAuthorized bool `json:"cookie_access_authorized"` + ArbitraryMethodAuthorized bool `json:"arbitrary_method_authorized"` + InstructionAuthorized bool `json:"instruction_authorized"` + Confirmed bool `json:"confirmed"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + Fingerprint string `json:"fingerprint"` +} + +// AuthorizeFullCDP issues a highly-sensitive CDP authorization only when the +// Run uses the maximum-access debug permission (with operator confirmation), +// the process enabled the full-debug gate, and the caller supplies an exact +// per-call confirmation. The capability is TTL-bounded and bound to the Run, +// Workspace, executable identity, permission revision, and scope; it never +// authorizes webpage-instruction elevation. +func AuthorizeFullCDP(session SessionPlan, identity BrowserExecutableIdentity, + acceptance BrowserAcceptanceCandidate, permission domain.RunBrowserCDPPermissionSnapshot, + runtimeCapabilities ProductionRuntimeCapabilities, + permissionCapabilities domain.BrowserCDPPermissionRuntimeCapabilities, + confirmed bool, now time.Time, +) (FullCDPAuthorization, error) { + if err := session.Validate(); err != nil { + return FullCDPAuthorization{}, err + } + if err := ValidateBrowserExecutableIdentity(identity); err != nil { + return FullCDPAuthorization{}, err + } + if err := ValidateBrowserAcceptanceCandidate(acceptance, identity); err != nil { + return FullCDPAuthorization{}, err + } + if err := validateFullCDPSession(session); err != nil { + return FullCDPAuthorization{}, err + } + if err := runtimeCapabilities.Validate(); err != nil { + return FullCDPAuthorization{}, err + } + if err := permissionCapabilities.Validate(); err != nil { + return FullCDPAuthorization{}, err + } + now = now.UTC() + if permission.Mode != domain.RunBrowserCDPPermissionFullDebug || + !permission.OperatorConfirmed || permission.RunID != session.RunID || + !permissionCapabilities.FullDebugEnabled || !runtimeCapabilities.RestrictedCDPEnabled || + !confirmed || now.IsZero() { + return FullCDPAuthorization{}, errors.New( + "full CDP debug requires maximum-access debug permission, per-call confirmation, and a live restricted-CDP runtime") + } + expiresAt := now.Add(FullCDPCapabilityTTL) + authorization := FullCDPAuthorization{ + ProtocolVersion: FullCDPAuthorizationProtocolVersion, + RunID: session.RunID, + WorkspaceID: session.WorkspaceID, + ExecutableIdentityFingerprint: identity.Fingerprint, + PermissionSnapshotID: permission.ID, + PermissionRevision: permission.Revision, + ScopeFingerprint: session.Scope.Fingerprint, + NavigateAuthorized: true, + DOMMetadataAuthorized: true, + ScreenshotAuthorized: true, + RequestCaptureAuthorized: true, + RequestMutationAuthorized: true, + RequestReplayAuthorized: true, + CookieAccessAuthorized: true, + ArbitraryMethodAuthorized: true, + InstructionAuthorized: false, + Confirmed: true, + IssuedAt: now, + ExpiresAt: expiresAt, + } + authorization.Fingerprint = browserRuntimeFingerprint(authorization) + if err := ValidateFullCDPAuthorization(authorization, session, identity, permission); err != nil { + return FullCDPAuthorization{}, err + } + return authorization, nil +} + +// ValidateFullCDPAuthorization re-checks that a Full CDP authorization still +// binds the maximum-access debug permission, session scope, executable +// identity, Run, and Workspace, and that its capability was confirmed, +// TTL-bounded, and never grants webpage-instruction elevation. +func ValidateFullCDPAuthorization(authorization FullCDPAuthorization, + session SessionPlan, identity BrowserExecutableIdentity, + permission domain.RunBrowserCDPPermissionSnapshot, +) error { + if err := session.Validate(); err != nil { + return err + } + if err := ValidateBrowserExecutableIdentity(identity); err != nil { + return err + } + if err := validateFullCDPSession(session); err != nil { + return err + } + if err := permission.Validate(); err != nil { + return err + } + if authorization.ProtocolVersion != FullCDPAuthorizationProtocolVersion || + authorization.RunID != session.RunID || + authorization.WorkspaceID != session.WorkspaceID || + authorization.ExecutableIdentityFingerprint != identity.Fingerprint || + authorization.PermissionSnapshotID != permission.ID || + authorization.PermissionRevision != permission.Revision || + authorization.ScopeFingerprint != session.Scope.Fingerprint || + permission.Mode != domain.RunBrowserCDPPermissionFullDebug || + !permission.OperatorConfirmed || permission.RunID != session.RunID || + !authorization.NavigateAuthorized || !authorization.DOMMetadataAuthorized || + !authorization.ScreenshotAuthorized || !authorization.RequestCaptureAuthorized || + !authorization.RequestMutationAuthorized || !authorization.RequestReplayAuthorized || + !authorization.CookieAccessAuthorized || !authorization.ArbitraryMethodAuthorized || + authorization.InstructionAuthorized || !authorization.Confirmed || + authorization.IssuedAt.IsZero() || authorization.ExpiresAt.IsZero() || + !authorization.ExpiresAt.After(authorization.IssuedAt) || + authorization.ExpiresAt.Sub(authorization.IssuedAt) > FullCDPCapabilityTTL || + authorization.Fingerprint != browserRuntimeFingerprint(authorization) { + return errors.New("full CDP authorization lost its highly-sensitive boundary") + } + return nil +} + +// validateFullCDPSession accepts only the CTF lab profile (full request +// interception/mutation/replay/cookie tools) with a single literal loopback +// target and direct proxy, while still forbidding any origin-policy or +// certificate relaxation. Full CDP never disables browser security. +func validateFullCDPSession(session SessionPlan) error { + if err := session.Validate(); err != nil { + return err + } + if session.ProfileID != ProfileCTFLab || session.Proxy.Mode != ProxyModeDirect || + len(session.Scope.Origins) != 1 || + !session.Features.RequestInterception || !session.Features.RequestMutation || + !session.Features.RequestReplay || !session.Features.CookieEditing || + session.Features.RelaxOriginPolicy || session.Features.AllowInsecureContent || + session.Features.IgnoreCertificateErrors { + return errors.New( + "full CDP requires the CTF lab profile with full request tools and preserved security") + } + origin := session.Scope.Origins[0] + address, err := netip.ParseAddr(origin.Host) + if err != nil || !address.Unmap().IsLoopback() || origin.HostClass != HostClassLoopback || + origin.ResolutionCheckRequired { + return fmt.Errorf("full CDP requires a literal loopback target, got %q", origin.Host) + } + return nil +} diff --git a/internal/browserruntime/full_cdp_authorization_test.go b/internal/browserruntime/full_cdp_authorization_test.go new file mode 100644 index 00000000..08fe7edf --- /dev/null +++ b/internal/browserruntime/full_cdp_authorization_test.go @@ -0,0 +1,173 @@ +package browserruntime + +import ( + "strings" + "testing" + "time" + + "cyberagent-workbench/internal/domain" +) + +func fullCDPAuthorizationFacts(t *testing.T) (SessionPlan, BrowserExecutableIdentity, + BrowserAcceptanceCandidate, domain.RunBrowserCDPPermissionSnapshot, +) { + t.Helper() + _, identity, acceptance, _ := browserLaunchFixture(t) + now := time.Now().UTC().Round(time.Millisecond) + session, err := BuildSessionPlan(NewSessionPlanRequest{ + SessionID: "session-full-cdp", RunID: "run-full-cdp", + WorkspaceID: "workspace-full-cdp", ProfileID: ProfileCTFLab, + Targets: []string{"http://127.0.0.1:18080"}, + Features: FeatureRequest{ + InterceptRequests: true, ModifyRequests: true, + ReplayRequests: true, EditCookies: true, + }, + }) + if err != nil { + t.Fatal(err) + } + permission, err := domain.NewInitialRunBrowserCDPPermissionSnapshot( + "browser-cdp-full", domain.Run{ID: session.RunID, + MissionID: "mission-full-cdp", Status: domain.RunCreated, CreatedAt: now}, + domain.Mission{ID: "mission-full-cdp", CreatedAt: now}, + "runtime-test-operator", now) + if err != nil { + t.Fatal(err) + } + full, err := permission.Next("browser-cdp-full-debug", + domain.RunBrowserCDPPermissionFullDebug, true, "runtime-test-operator", + "test full debug boundary", now) + if err != nil { + t.Fatal(err) + } + return session, identity, acceptance, full +} + +func TestAuthorizeFullCDPRequiresMaximumAccessDebugAndConfirmation(t *testing.T) { + session, identity, acceptance, full := fullCDPAuthorizationFacts(t) + runtimeCapabilities := ProductionRuntimeCapabilities{ + SafeWebStartEnabled: true, DisposableProfileEnabled: true, + NetworkContainmentEnabled: true, RestrictedCDPEnabled: true, + } + permissionCapabilities := domain.BrowserCDPPermissionRuntimeCapabilities{ + ControlEnabled: true, FullDebugEnabled: true, + } + now := time.Now().UTC().Add(time.Second) + + authorization, err := AuthorizeFullCDP(session, identity, acceptance, full, + runtimeCapabilities, permissionCapabilities, true, now) + if err != nil { + t.Fatal(err) + } + if !authorization.RequestCaptureAuthorized || !authorization.RequestMutationAuthorized || + !authorization.RequestReplayAuthorized || !authorization.CookieAccessAuthorized || + !authorization.ArbitraryMethodAuthorized || authorization.InstructionAuthorized || + !authorization.Confirmed || authorization.ExpiresAt.After(now.Add(FullCDPCapabilityTTL)) { + t.Fatalf("full CDP authorization has wrong sensitive boundary: %#v", authorization) + } + if err := ValidateFullCDPAuthorization(authorization, session, identity, full); err != nil { + t.Fatal(err) + } +} + +func TestAuthorizeFullCDPRejectsWithoutDebugMaximumAccessOrConfirmation(t *testing.T) { + session, identity, acceptance, full := fullCDPAuthorizationFacts(t) + runtimeCapabilities := ProductionRuntimeCapabilities{ + SafeWebStartEnabled: true, DisposableProfileEnabled: true, + NetworkContainmentEnabled: true, RestrictedCDPEnabled: true, + } + permissionCapabilities := domain.BrowserCDPPermissionRuntimeCapabilities{ + ControlEnabled: true, FullDebugEnabled: true, + } + now := time.Now().UTC().Add(time.Second) + + t.Run("safe web session", func(t *testing.T) { + safeSession, err := BuildSessionPlan(NewSessionPlanRequest{ + SessionID: "session-safe-web", RunID: session.RunID, + WorkspaceID: session.WorkspaceID, ProfileID: ProfileSafeWeb, + Targets: []string{"http://127.0.0.1:18080"}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := AuthorizeFullCDP(safeSession, identity, acceptance, full, + runtimeCapabilities, permissionCapabilities, true, now); err == nil { + t.Fatal("Safe Web session unexpectedly authorized full CDP") + } + }) + t.Run("restricted permission", func(t *testing.T) { + if _, err := AuthorizeFullCDP(session, identity, acceptance, + permissionForFullCDP(t, session), runtimeCapabilities, + permissionCapabilities, true, now); err == nil { + t.Fatal("restricted permission unexpectedly authorized full CDP") + } + }) + t.Run("not confirmed", func(t *testing.T) { + if _, err := AuthorizeFullCDP(session, identity, acceptance, full, + runtimeCapabilities, permissionCapabilities, false, now); err == nil { + t.Fatal("unconfirmed full CDP unexpectedly authorized") + } + }) + t.Run("full debug gate disabled", func(t *testing.T) { + if _, err := AuthorizeFullCDP(session, identity, acceptance, full, + runtimeCapabilities, + domain.BrowserCDPPermissionRuntimeCapabilities{ControlEnabled: true}, + true, now); err == nil { + t.Fatal("disabled full-debug gate unexpectedly authorized full CDP") + } + }) + t.Run("restricted CDP runtime disabled", func(t *testing.T) { + if _, err := AuthorizeFullCDP(session, identity, acceptance, full, + ProductionRuntimeCapabilities{ + SafeWebStartEnabled: true, DisposableProfileEnabled: true, + NetworkContainmentEnabled: true, + }, permissionCapabilities, true, now); err == nil { + t.Fatal("disabled restricted-CDP runtime unexpectedly authorized full CDP") + } + }) +} + +func permissionForFullCDP(t *testing.T, session SessionPlan) domain.RunBrowserCDPPermissionSnapshot { + t.Helper() + now := time.Now().UTC().Round(time.Millisecond) + permission, err := domain.NewInitialRunBrowserCDPPermissionSnapshot( + "browser-cdp-restricted", domain.Run{ID: session.RunID, + MissionID: "mission-full-cdp", Status: domain.RunCreated, CreatedAt: now}, + domain.Mission{ID: "mission-full-cdp", CreatedAt: now}, + "runtime-test-operator", now) + if err != nil { + t.Fatal(err) + } + return permission +} + +func TestValidateFullCDPAuthorizationRejectsTampering(t *testing.T) { + session, identity, acceptance, full := fullCDPAuthorizationFacts(t) + runtimeCapabilities := ProductionRuntimeCapabilities{ + SafeWebStartEnabled: true, DisposableProfileEnabled: true, + NetworkContainmentEnabled: true, RestrictedCDPEnabled: true, + } + authorization, err := AuthorizeFullCDP(session, identity, acceptance, full, + runtimeCapabilities, + domain.BrowserCDPPermissionRuntimeCapabilities{ControlEnabled: true, + FullDebugEnabled: true}, true, time.Now().UTC().Add(time.Second)) + if err != nil { + t.Fatal(err) + } + for name, tamper := range map[string]func(*FullCDPAuthorization){ + "unconfirmed": func(a *FullCDPAuthorization) { a.Confirmed = false }, + "instruction": func(a *FullCDPAuthorization) { a.InstructionAuthorized = true }, + "scope_forged": func(a *FullCDPAuthorization) { a.ScopeFingerprint = strings.Repeat("e", 64) }, + "ttl_extended": func(a *FullCDPAuthorization) { a.ExpiresAt = a.IssuedAt.Add(time.Hour) }, + "run_forged": func(a *FullCDPAuthorization) { a.RunID = "other-run" }, + } { + t.Run(name, func(t *testing.T) { + receipt := authorization + tamper(&receipt) + if err := ValidateFullCDPAuthorization(receipt, session, + identity, full); err == nil { + t.Fatal("tampered full CDP authorization was accepted") + } + }) + } +} diff --git a/internal/browserruntime/full_cdp_start.go b/internal/browserruntime/full_cdp_start.go new file mode 100644 index 00000000..65c520de --- /dev/null +++ b/internal/browserruntime/full_cdp_start.go @@ -0,0 +1,157 @@ +package browserruntime + +import ( + "errors" + "time" + + "cyberagent-workbench/internal/domain" +) + +const FullCDPStartAuthorizationProtocolVersion = "browser_full_cdp_start_authorization.v1" + +// FullCDPStartAuthorization is the process-launch authorization for the Full +// CDP debug channel. It is independent from the Safe Web start authorization: +// it requires the maximum-access debug permission and never carries the Safe +// Web WFP containment or loopback-navigation flags. +type FullCDPStartAuthorization struct { + ProtocolVersion string `json:"protocol_version"` + SessionPlanFingerprint string `json:"session_plan_fingerprint"` + ExecutableIdentityFingerprint string `json:"executable_identity_fingerprint"` + AcceptanceFingerprint string `json:"acceptance_fingerprint"` + ProfileOwnershipFingerprint string `json:"profile_ownership_fingerprint"` + AttemptFingerprint string `json:"attempt_fingerprint"` + LeaseFingerprint string `json:"lease_fingerprint"` + ReviewFingerprint string `json:"review_fingerprint"` + PermissionSnapshotID string `json:"permission_snapshot_id"` + PermissionRevision int64 `json:"permission_revision"` + ScopeFingerprint string `json:"scope_fingerprint"` + ProfileGeneration uint64 `json:"profile_generation"` + ProcessStartAuthorized bool `json:"process_start_authorized"` + ProcessTerminationAuthorized bool `json:"process_termination_authorized"` + ProfileCreateAuthorized bool `json:"profile_create_authorized"` + ProfileReleaseAuthorized bool `json:"profile_release_authorized"` + ExactOwnedCleanupAuthorized bool `json:"exact_owned_cleanup_authorized"` + IssuedAt time.Time `json:"issued_at"` + StartDeadline time.Time `json:"start_deadline"` + RuntimeDeadline time.Time `json:"runtime_deadline"` + Fingerprint string `json:"fingerprint"` +} + +// AuthorizeFullCDPStart issues the process-launch authorization for the Full +// CDP channel. It requires the maximum-access debug permission (with operator +// confirmation), the full-debug process gate, and a live launch lease, and it +// never grants the Safe Web network-containment or loopback-navigation flags. +func AuthorizeFullCDPStart(session SessionPlan, identity BrowserExecutableIdentity, + acceptance BrowserAcceptanceCandidate, ownership ProfileOwnershipPlan, + attempt BrowserLaunchAttempt, lease BrowserLaunchLease, review BrowserLaunchReview, + permission domain.RunBrowserCDPPermissionSnapshot, + runtimeCapabilities ProductionRuntimeCapabilities, + permissionCapabilities domain.BrowserCDPPermissionRuntimeCapabilities, now time.Time, +) (FullCDPStartAuthorization, error) { + if err := ValidateBrowserLaunchReview(review, session, identity, acceptance, + ownership, attempt, lease); err != nil { + return FullCDPStartAuthorization{}, err + } + if err := permission.Validate(); err != nil { + return FullCDPStartAuthorization{}, err + } + if err := permissionCapabilities.Validate(); err != nil { + return FullCDPStartAuthorization{}, err + } + if err := runtimeCapabilities.Validate(); err != nil { + return FullCDPStartAuthorization{}, err + } + if err := validateFullCDPSession(session); err != nil { + return FullCDPStartAuthorization{}, err + } + if !review.AcceptedForFutureAdapter || permission.Mode != domain.RunBrowserCDPPermissionFullDebug || + !permission.OperatorConfirmed || !permissionCapabilities.FullDebugEnabled || + !runtimeCapabilities.RestrictedCDPEnabled { + return FullCDPStartAuthorization{}, errors.New( + "full CDP launch requires the maximum-access debug permission and the full-debug gate") + } + now = now.UTC() + if now.IsZero() || now.Before(review.CreatedAt) || !now.Before(lease.ExpiresAt) { + return FullCDPStartAuthorization{}, errors.New("full CDP launch lease is not active") + } + runtimeDeadline := now.Add(time.Duration(attempt.MaxRuntimeMS) * time.Millisecond) + authorization := FullCDPStartAuthorization{ + ProtocolVersion: FullCDPStartAuthorizationProtocolVersion, + SessionPlanFingerprint: session.Fingerprint, + ExecutableIdentityFingerprint: identity.Fingerprint, + AcceptanceFingerprint: acceptance.Fingerprint, + ProfileOwnershipFingerprint: ownership.Fingerprint, + AttemptFingerprint: attempt.Fingerprint, + LeaseFingerprint: lease.Fingerprint, + ReviewFingerprint: review.Fingerprint, + PermissionSnapshotID: permission.ID, + PermissionRevision: permission.Revision, + ScopeFingerprint: session.Scope.Fingerprint, + ProfileGeneration: ownership.Generation, + ProcessStartAuthorized: true, + ProcessTerminationAuthorized: true, + ProfileCreateAuthorized: true, + ProfileReleaseAuthorized: true, + ExactOwnedCleanupAuthorized: true, + IssuedAt: now, + StartDeadline: lease.ExpiresAt, + RuntimeDeadline: runtimeDeadline, + } + authorization.Fingerprint = browserRuntimeFingerprint(authorization) + if err := ValidateFullCDPStartAuthorization(authorization, session, identity, + acceptance, ownership, attempt, lease, review, permission); err != nil { + return FullCDPStartAuthorization{}, err + } + return authorization, nil +} + +// ValidateFullCDPStartAuthorization re-checks that a Full CDP launch +// authorization still binds the maximum-access debug permission, session, +// executable, ownership, attempt, lease, and review, and that it carries the +// process-launch authority without any Safe Web containment flag. +func ValidateFullCDPStartAuthorization(authorization FullCDPStartAuthorization, + session SessionPlan, identity BrowserExecutableIdentity, + acceptance BrowserAcceptanceCandidate, ownership ProfileOwnershipPlan, + attempt BrowserLaunchAttempt, lease BrowserLaunchLease, review BrowserLaunchReview, + permission domain.RunBrowserCDPPermissionSnapshot, +) error { + if err := ValidateBrowserLaunchReview(review, session, identity, acceptance, + ownership, attempt, lease); err != nil { + return err + } + if err := permission.Validate(); err != nil { + return err + } + if err := validateFullCDPSession(session); err != nil { + return err + } + if authorization.ProtocolVersion != FullCDPStartAuthorizationProtocolVersion || + authorization.SessionPlanFingerprint != session.Fingerprint || + authorization.ExecutableIdentityFingerprint != identity.Fingerprint || + authorization.AcceptanceFingerprint != acceptance.Fingerprint || + authorization.ProfileOwnershipFingerprint != ownership.Fingerprint || + authorization.AttemptFingerprint != attempt.Fingerprint || + authorization.LeaseFingerprint != lease.Fingerprint || + authorization.ReviewFingerprint != review.Fingerprint || + authorization.PermissionSnapshotID != permission.ID || + authorization.PermissionRevision != permission.Revision || + authorization.ScopeFingerprint != session.Scope.Fingerprint || + authorization.ProfileGeneration != ownership.Generation || + permission.Mode != domain.RunBrowserCDPPermissionFullDebug || + !permission.OperatorConfirmed || + !authorization.ProcessStartAuthorized || !authorization.ProcessTerminationAuthorized || + !authorization.ProfileCreateAuthorized || !authorization.ProfileReleaseAuthorized || + !authorization.ExactOwnedCleanupAuthorized || + authorization.IssuedAt.IsZero() || authorization.StartDeadline.IsZero() || + authorization.RuntimeDeadline.IsZero() || + !authorization.StartDeadline.Equal(lease.ExpiresAt) || + authorization.IssuedAt.Before(review.CreatedAt) || + !authorization.StartDeadline.After(authorization.IssuedAt) || + !authorization.RuntimeDeadline.After(authorization.IssuedAt) || + authorization.RuntimeDeadline.Sub(authorization.IssuedAt) != + time.Duration(attempt.MaxRuntimeMS)*time.Millisecond || + authorization.Fingerprint != browserRuntimeFingerprint(authorization) { + return errors.New("full CDP start authorization lost its independent launch boundary") + } + return nil +} diff --git a/internal/browserruntime/full_cdp_start_test.go b/internal/browserruntime/full_cdp_start_test.go new file mode 100644 index 00000000..8d057fc5 --- /dev/null +++ b/internal/browserruntime/full_cdp_start_test.go @@ -0,0 +1,89 @@ +package browserruntime + +import ( + "path/filepath" + "testing" + "time" + + "cyberagent-workbench/internal/domain" +) + +func fullCDPLaunchFacts(t *testing.T) (SessionPlan, BrowserExecutableIdentity, + BrowserAcceptanceCandidate, ProfileOwnershipPlan, BrowserLaunchAttempt, + BrowserLaunchLease, BrowserLaunchReview, domain.RunBrowserCDPPermissionSnapshot, +) { + t.Helper() + session, identity, acceptance, permission := fullCDPAuthorizationFacts(t) + now := time.Now().UTC().Round(time.Millisecond) + ownership, err := BuildProfileOwnershipPlan(session, identity, + filepath.Join(directTestPath(t, t.TempDir()), ProfileRuntimeRootName)) + if err != nil { + t.Fatal(err) + } + attempt, err := BuildBrowserLaunchAttempt(session, identity, acceptance, ownership, + "browser-attempt-full-cdp", now) + if err != nil { + t.Fatal(err) + } + lease, err := BuildBrowserLaunchLease(attempt, "browser-lease-full-cdp", + "browser-full-cdp-worker", now, 2*time.Minute) + if err != nil { + t.Fatal(err) + } + review, err := BuildBrowserLaunchReview(session, identity, acceptance, ownership, + attempt, lease, "browser-review-full-cdp", BrowserLaunchReviewAcceptCandidate, + "independent-runtime-operator", "browser-review-full-cdp-operation", "", + now.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + return session, identity, acceptance, ownership, attempt, lease, review, permission +} + +func TestAuthorizeFullCDPStartRequiresMaximumAccessDebug(t *testing.T) { + session, identity, acceptance, ownership, attempt, lease, review, + permission := fullCDPLaunchFacts(t) + runtimeCapabilities := ProductionRuntimeCapabilities{ + SafeWebStartEnabled: true, DisposableProfileEnabled: true, + NetworkContainmentEnabled: true, RestrictedCDPEnabled: true, + } + permissionCapabilities := domain.BrowserCDPPermissionRuntimeCapabilities{ + ControlEnabled: true, FullDebugEnabled: true, + } + now := time.Now().UTC().Add(2 * time.Second) + + authorization, err := AuthorizeFullCDPStart(session, identity, acceptance, + ownership, attempt, lease, review, permission, runtimeCapabilities, + permissionCapabilities, now) + if err != nil { + t.Fatal(err) + } + if !authorization.ProcessStartAuthorized || !authorization.ProcessTerminationAuthorized || + !authorization.ProfileCreateAuthorized || !authorization.ProfileReleaseAuthorized || + !authorization.ExactOwnedCleanupAuthorized { + t.Fatalf("full CDP start authorization has wrong process authority: %#v", authorization) + } + if err := ValidateFullCDPStartAuthorization(authorization, session, identity, + acceptance, ownership, attempt, lease, review, permission); err != nil { + t.Fatal(err) + } +} + +func TestAuthorizeFullCDPStartRejectsRestrictedPermission(t *testing.T) { + session, identity, acceptance, ownership, attempt, lease, review, + _ := fullCDPLaunchFacts(t) + runtimeCapabilities := ProductionRuntimeCapabilities{ + SafeWebStartEnabled: true, DisposableProfileEnabled: true, + NetworkContainmentEnabled: true, RestrictedCDPEnabled: true, + } + permissionCapabilities := domain.BrowserCDPPermissionRuntimeCapabilities{ + ControlEnabled: true, FullDebugEnabled: true, + } + restricted := permissionForFullCDP(t, session) + now := time.Now().UTC().Add(2 * time.Second) + if _, err := AuthorizeFullCDPStart(session, identity, acceptance, ownership, + attempt, lease, review, restricted, runtimeCapabilities, + permissionCapabilities, now); err == nil { + t.Fatal("restricted permission unexpectedly authorized a full CDP launch") + } +} diff --git a/internal/browserruntime/full_cdp_transport.go b/internal/browserruntime/full_cdp_transport.go new file mode 100644 index 00000000..3e0ec714 --- /dev/null +++ b/internal/browserruntime/full_cdp_transport.go @@ -0,0 +1,224 @@ +package browserruntime + +import ( + "context" + "errors" + "time" + + "cyberagent-workbench/internal/domain" +) + +const ( + FullCDPRequestCaptureProtocolVersion = "full_cdp_request_capture.v1" + FullCDPCookieAccessProtocolVersion = "full_cdp_cookie_access.v1" +) + +// FullCDPRequestCapture is bounded request metadata only. It never retains +// headers, cookies, response bodies, or any secret-bearing field. +type FullCDPRequestCapture struct { + ProtocolVersion string `json:"protocol_version"` + Authorization string `json:"authorization_fingerprint"` + AllowedRequests int `json:"allowed_requests"` + BlockedRequests int `json:"blocked_requests"` + CapturedCount int `json:"captured_count"` + CompletedAt time.Time `json:"completed_at"` + Fingerprint string `json:"fingerprint"` +} + +// FullCDPCookieAccess is bounded cookie-name metadata only. Cookie values, +// headers, and other secret-bearing fields are never retained. +type FullCDPCookieAccess struct { + ProtocolVersion string `json:"protocol_version"` + Authorization string `json:"authorization_fingerprint"` + CookieNames []string `json:"cookie_names"` + CompletedAt time.Time `json:"completed_at"` + Fingerprint string `json:"fingerprint"` +} + +// FullCDPSession is the highly-sensitive CDP debug channel. It is independent +// from the Safe Web restricted session and only operates under a confirmed, +// TTL-bounded FullCDPAuthorization. Every result is metadata-only: no raw +// request body, header, cookie value, or page content is ever returned. +type FullCDPSession struct { + authorization FullCDPAuthorization + session SessionPlan + permission domain.RunBrowserCDPPermissionSnapshot + identity BrowserExecutableIdentity + client *restrictedCDPClient + process *BrowserProcess + operation chan struct{} + closed chan struct{} +} + +// OpenFullCDPSession dials a dedicated DevTools endpoint for the already +// launched, contained browser process and returns a Full CDP session gated by +// the confirmed FullCDPAuthorization. It never connects to an existing browser. +func OpenFullCDPSession(ctx context.Context, authorization FullCDPAuthorization, + session SessionPlan, identity BrowserExecutableIdentity, + acceptance BrowserAcceptanceCandidate, ownership ProfileOwnershipPlan, + permission domain.RunBrowserCDPPermissionSnapshot, + profileLease ProfileRuntimeLease, process *BrowserProcess, +) (*FullCDPSession, error) { + if err := ValidateFullCDPAuthorization(authorization, session, identity, permission); err != nil { + return nil, err + } + if err := ValidateBrowserAcceptanceCandidate(acceptance, identity); err != nil { + return nil, err + } + if err := ValidateProfileOwnershipPlan(ownership, session, identity); err != nil { + return nil, err + } + if process == nil || process.PID() <= 0 { + return nil, errors.New("full CDP requires the exact live browser process") + } + if _, exited := process.Exit(); exited { + return nil, errors.New("full CDP browser process already exited") + } + if ctx == nil { + ctx = context.Background() + } + openContext, cancel := boundedRestrictedCDPContext(ctx, authorization.ExpiresAt) + defer cancel() + endpoint, err := waitForDevToolsEndpoint(openContext, profileLease.DirectoryPath, process) + if err != nil { + return nil, err + } + connection, err := dialRestrictedCDP(openContext, endpoint) + if err != nil { + return nil, err + } + client := &restrictedCDPClient{conn: connection, scope: session.Scope, + maxRequests: session.Limits.MaxRequests, fullCDP: true} + if err := client.initialize(openContext); err != nil { + _ = connection.Close() + return nil, err + } + runtime := &FullCDPSession{ + authorization: authorization, session: session, permission: permission, + identity: identity, client: client, process: process, + operation: make(chan struct{}, 1), closed: make(chan struct{}), + } + runtime.operation <- struct{}{} + go runtime.closeWhenProcessExits() + return runtime, nil +} + +func (runtime *FullCDPSession) RequestCapture(ctx context.Context, +) (FullCDPRequestCapture, error) { + if runtime == nil || !runtime.authorization.RequestCaptureAuthorized { + return FullCDPRequestCapture{}, ErrBrowserRuntimeBoundary + } + release, operationContext, cancel, err := runtime.beginOperation(ctx) + if err != nil { + return FullCDPRequestCapture{}, err + } + defer release() + defer cancel() + if err := operationContext.Err(); err != nil { + return FullCDPRequestCapture{}, err + } + result := FullCDPRequestCapture{ + ProtocolVersion: FullCDPRequestCaptureProtocolVersion, + Authorization: runtime.authorization.Fingerprint, + AllowedRequests: runtime.client.allowedRequests, + BlockedRequests: runtime.client.blockedRequests, + CapturedCount: len(runtime.client.capturedRequests), + CompletedAt: time.Now().UTC(), + } + result.Fingerprint = browserRuntimeFingerprint(result) + return result, nil +} + +func (runtime *FullCDPSession) CookieAccess(ctx context.Context, +) (FullCDPCookieAccess, error) { + if runtime == nil || !runtime.authorization.CookieAccessAuthorized { + return FullCDPCookieAccess{}, ErrBrowserRuntimeBoundary + } + release, operationContext, cancel, err := runtime.beginOperation(ctx) + if err != nil { + return FullCDPCookieAccess{}, err + } + defer release() + defer cancel() + var cookies struct { + Cookies []struct { + Name string `json:"name"` + } `json:"cookies"` + } + if err := runtime.client.call(operationContext, runtime.client.sessionID, + "Network.getCookies", map[string]any{}, &cookies); err != nil { + return FullCDPCookieAccess{}, err + } + names := make([]string, 0, len(cookies.Cookies)) + for _, cookie := range cookies.Cookies { + if cookie.Name != "" && len(cookie.Name) <= 256 { + names = append(names, cookie.Name) + } + } + result := FullCDPCookieAccess{ + ProtocolVersion: FullCDPCookieAccessProtocolVersion, + Authorization: runtime.authorization.Fingerprint, + CookieNames: names, + CompletedAt: time.Now().UTC(), + } + result.Fingerprint = browserRuntimeFingerprint(result) + return result, nil +} + +func (runtime *FullCDPSession) Close(ctx context.Context) error { + if runtime == nil { + return nil + } + select { + case <-runtime.operation: + case <-runtime.closed: + return nil + case <-contextDone(ctx): + return contextError(ctx) + } + close(runtime.closed) + closeContext, cancel := boundedRestrictedCDPContext(ctx, time.Now().Add(time.Second)) + defer cancel() + return runtime.client.close(closeContext) +} + +func (runtime *FullCDPSession) beginOperation(ctx context.Context, +) (func(), context.Context, context.CancelFunc, error) { + if err := ValidateFullCDPAuthorization(runtime.authorization, runtime.session, + runtime.identity, runtime.permission); err != nil { + return nil, nil, nil, err + } + if !time.Now().UTC().Before(runtime.authorization.ExpiresAt) { + return nil, nil, nil, errors.New("full CDP authorization expired") + } + if _, exited := runtime.process.Exit(); exited { + return nil, nil, nil, errors.New("full CDP browser process exited") + } + if ctx == nil { + ctx = context.Background() + } + select { + case <-ctx.Done(): + return nil, nil, nil, ctx.Err() + case <-runtime.closed: + return nil, nil, nil, errors.New("full CDP session is closed") + case <-runtime.operation: + } + operationContext, cancel := boundedRestrictedCDPContext(ctx, + runtime.authorization.ExpiresAt) + release := func() { + select { + case runtime.operation <- struct{}{}: + case <-runtime.closed: + } + } + return release, operationContext, cancel, nil +} + +func (runtime *FullCDPSession) closeWhenProcessExits() { + select { + case <-runtime.process.Done(): + _ = runtime.Close(context.Background()) + case <-runtime.closed: + } +} diff --git a/internal/browserruntime/restricted_cdp_transport.go b/internal/browserruntime/restricted_cdp_transport.go index c23b55e4..2544593e 100644 --- a/internal/browserruntime/restricted_cdp_transport.go +++ b/internal/browserruntime/restricted_cdp_transport.go @@ -66,6 +66,19 @@ var restrictedCDPMethods = map[string]restrictedCDPMethodScope{ "Page.captureScreenshot": restrictedCDPTargetMethod, } +// fullCDPMethods is the additional, highly-sensitive CDP method set admitted +// only under a confirmed FullCDPAuthorization. It never includes methods that +// disable browser security. +var fullCDPMethods = map[string]restrictedCDPMethodScope{ + "Network.getCookies": restrictedCDPTargetMethod, + "Network.getAllCookies": restrictedCDPTargetMethod, + "Storage.getCookies": restrictedCDPTargetMethod, + "Fetch.fulfillRequest": restrictedCDPTargetMethod, + "Runtime.enable": restrictedCDPTargetMethod, + "Runtime.evaluate": restrictedCDPTargetMethod, + "Log.enable": restrictedCDPTargetMethod, +} + type RestrictedNavigationResult struct { ProtocolVersion string `json:"protocol_version"` Authorization string `json:"authorization_fingerprint"` @@ -136,6 +149,16 @@ type restrictedCDPClient struct { blockedRequests int blockedDocument bool budgetErr error + capturedRequests []capturedRequestMetadata + fullCDP bool +} + +// capturedRequestMetadata is bounded request metadata only. It never retains +// headers, cookies, bodies, or any secret-bearing field. +type capturedRequestMetadata struct { + URL string `json:"url"` + Method string `json:"method"` + ResourceType string `json:"resource_type"` } type cdpWireMessage struct { @@ -551,7 +574,12 @@ func (client *restrictedCDPClient) methodSessionAllowed(method string, ) bool { scope, ok := restrictedCDPMethods[method] if !ok { - return false + if client.fullCDP { + scope, ok = fullCDPMethods[method] + } + if !ok { + return false + } } if scope == restrictedCDPBrowserMethod { return sessionID == "" @@ -598,13 +626,20 @@ func (client *restrictedCDPClient) handleEvent(ctx context.Context, RequestID string `json:"requestId"` ResourceType string `json:"resourceType"` Request struct { - URL string `json:"url"` + URL string `json:"url"` + Method string `json:"method"` } `json:"request"` } if err := json.Unmarshal(message.Params, &paused); err != nil || !validRestrictedCDPToken(paused.RequestID) { return errors.New("restricted CDP request event is malformed") } + if len(client.capturedRequests) < client.maxRequests { + client.capturedRequests = append(client.capturedRequests, capturedRequestMetadata{ + URL: paused.Request.URL, Method: paused.Request.Method, + ResourceType: paused.ResourceType, + }) + } decision := client.scope.AuthorizeNavigation(paused.Request.URL) if decision.Allowed && client.allowedRequests+client.blockedRequests < client.maxRequests { client.allowedRequests++