feat: add read-only CDP target viewing
This commit is contained in:
@@ -5,18 +5,26 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"chub/internal/domain"
|
||||
)
|
||||
|
||||
const remoteDebugPortAttempts = 100
|
||||
const (
|
||||
remoteDebugPortAttempts = 100
|
||||
remoteDebugTargetsMaxBytes = 1 << 20
|
||||
remoteDebugTargetsMaxCount = 200
|
||||
remoteDebugTargetTextMax = 4096
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRemoteDebugPortUnavailable = errors.New("remote debugging port is unavailable")
|
||||
@@ -28,6 +36,16 @@ type RemoteDebugEndpoint struct {
|
||||
Browser string
|
||||
}
|
||||
|
||||
// RemoteDebugTarget is a read-only summary of one target returned by CDP
|
||||
// /json/list. URL is safe to display but must never be treated as a control
|
||||
// endpoint; its userinfo, query, and fragment have been removed.
|
||||
type RemoteDebugTarget struct {
|
||||
ID string
|
||||
Type string
|
||||
Title string
|
||||
URL string
|
||||
}
|
||||
|
||||
type RemoteDebugEndpointInspector interface {
|
||||
InspectRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (RemoteDebugEndpoint, error)
|
||||
WaitForRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (RemoteDebugEndpoint, error)
|
||||
@@ -39,6 +57,12 @@ type RemoteDebugPortAllocator interface {
|
||||
FindAvailableRemoteDebugPort(context.Context, int) (int, error)
|
||||
}
|
||||
|
||||
// RemoteDebugTargetInspector reads CDP targets only from a verified loopback
|
||||
// endpoint. It intentionally exposes no WebSocket URL or browser-control API.
|
||||
type RemoteDebugTargetInspector interface {
|
||||
ListRemoteDebugTargets(context.Context, domain.BrowserKind, int) ([]RemoteDebugTarget, error)
|
||||
}
|
||||
|
||||
type CDPInspector struct {
|
||||
readFile func(string) ([]byte, error)
|
||||
client *http.Client
|
||||
@@ -47,7 +71,10 @@ type CDPInspector struct {
|
||||
func NewCDPInspector() *CDPInspector {
|
||||
return &CDPInspector{
|
||||
readFile: os.ReadFile,
|
||||
client: &http.Client{Transport: &http.Transport{Proxy: nil}},
|
||||
client: &http.Client{
|
||||
Transport: &http.Transport{Proxy: nil},
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +143,66 @@ func (i *CDPInspector) WaitForRemoteDebugPort(ctx context.Context, kind domain.B
|
||||
})
|
||||
}
|
||||
|
||||
// ListRemoteDebugTargets first validates the browser kind at the supplied
|
||||
// loopback port, then reads its bounded /json/list response. It never follows
|
||||
// redirects or exposes the response's webSocketDebuggerUrl field.
|
||||
func (i *CDPInspector) ListRemoteDebugTargets(ctx context.Context, kind domain.BrowserKind, port int) ([]RemoteDebugTarget, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i == nil || i.client == nil || !kind.Valid() || !domain.ValidRemoteDebugPort(port) {
|
||||
return nil, ErrRemoteDebugEndpointUnavailable
|
||||
}
|
||||
if _, err := i.InspectRemoteDebugPort(ctx, kind, port); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:"+strconv.Itoa(port)+"/json/list", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: build target request", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
response, err := i.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: query targets", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: target status %d", ErrRemoteDebugEndpointUnavailable, response.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, remoteDebugTargetsMaxBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: read targets", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
if len(data) > remoteDebugTargetsMaxBytes {
|
||||
return nil, fmt.Errorf("%w: target response exceeds %d bytes", ErrRemoteDebugEndpointUnavailable, remoteDebugTargetsMaxBytes)
|
||||
}
|
||||
var rawTargets []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &rawTargets); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode targets", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
if len(rawTargets) > remoteDebugTargetsMaxCount {
|
||||
return nil, fmt.Errorf("%w: target count exceeds %d", ErrRemoteDebugEndpointUnavailable, remoteDebugTargetsMaxCount)
|
||||
}
|
||||
targets := make([]RemoteDebugTarget, 0, len(rawTargets))
|
||||
for _, raw := range rawTargets {
|
||||
id := strings.TrimSpace(raw.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, RemoteDebugTarget{
|
||||
ID: truncateRemoteDebugTargetText(id),
|
||||
Type: truncateRemoteDebugTargetText(strings.TrimSpace(raw.Type)),
|
||||
Title: truncateRemoteDebugTargetText(strings.TrimSpace(raw.Title)),
|
||||
URL: sanitizeRemoteDebugTargetURL(raw.URL),
|
||||
})
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func waitForRemoteDebugEndpoint(ctx context.Context, inspect func(context.Context) (RemoteDebugEndpoint, error)) (RemoteDebugEndpoint, error) {
|
||||
const pollInterval = 50 * time.Millisecond
|
||||
for {
|
||||
@@ -180,3 +267,26 @@ func browserVersionMatchesKind(browser string, kind domain.BrowserKind) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeRemoteDebugTargetURL(value string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil || parsed == nil {
|
||||
return ""
|
||||
}
|
||||
parsed.User = nil
|
||||
parsed.RawQuery = ""
|
||||
parsed.ForceQuery = false
|
||||
parsed.Fragment = ""
|
||||
return truncateRemoteDebugTargetText(parsed.String())
|
||||
}
|
||||
|
||||
func truncateRemoteDebugTargetText(value string) string {
|
||||
if len(value) <= remoteDebugTargetTextMax {
|
||||
return value
|
||||
}
|
||||
cut := remoteDebugTargetTextMax
|
||||
for cut > 0 && !utf8.RuneStart(value[cut]) {
|
||||
cut--
|
||||
}
|
||||
return value[:cut]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chub/internal/domain"
|
||||
@@ -93,3 +94,76 @@ func TestCDPInspectorFindsNextAvailablePort(t *testing.T) {
|
||||
t.Fatalf("port = %d, want %d", port, start+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPInspectorListsVerifiedTargetsWithoutControlEndpoints(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/json/version":
|
||||
_, _ = w.Write([]byte(`{"Browser":"Chrome/136.0.0.0"}`))
|
||||
case "/json/list":
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"id":"page-1","type":"page","title":"运营后台","url":"https://user:secret@example.com/orders?token=hidden#section","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/page/page-1"},
|
||||
{"id":"worker-1","type":"service_worker","title":"","url":"chrome-extension://example/worker.js?key=hidden"}
|
||||
]`))
|
||||
default:
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
parsed, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, portText, err := net.SplitHostPort(parsed.Host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, _ := strconv.Atoi(portText)
|
||||
|
||||
targets, err := NewCDPInspector().ListRemoteDebugTargets(context.Background(), domain.BrowserChrome, port)
|
||||
if err != nil || len(targets) != 2 {
|
||||
t.Fatalf("ListRemoteDebugTargets() = %#v, %v", targets, err)
|
||||
}
|
||||
if targets[0].ID != "page-1" || targets[0].Type != "page" || targets[0].Title != "运营后台" || targets[0].URL != "https://example.com/orders" {
|
||||
t.Fatalf("first target = %#v", targets[0])
|
||||
}
|
||||
if targets[1].URL != "chrome-extension://example/worker.js" {
|
||||
t.Fatalf("second target URL = %q", targets[1].URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPInspectorRejectsMalformedOrOversizedTargetList(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
status int
|
||||
response string
|
||||
}{
|
||||
{name: "malformed JSON", status: http.StatusOK, response: `not-json`},
|
||||
{name: "too many targets", status: http.StatusOK, response: `[` + strings.Repeat(`{"id":"page","type":"page"},`, remoteDebugTargetsMaxCount) + `{"id":"last","type":"page"}]`},
|
||||
{name: "non-success status", status: http.StatusServiceUnavailable, response: `temporarily unavailable`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/json/version" {
|
||||
_, _ = w.Write([]byte(`{"Browser":"Chrome/136.0.0.0"}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(test.status)
|
||||
_, _ = w.Write([]byte(test.response))
|
||||
}))
|
||||
defer server.Close()
|
||||
parsed, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, portText, err := net.SplitHostPort(parsed.Host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, _ := strconv.Atoi(portText)
|
||||
if _, err := NewCDPInspector().ListRemoteDebugTargets(context.Background(), domain.BrowserChrome, port); !errors.Is(err, ErrRemoteDebugEndpointUnavailable) {
|
||||
t.Fatalf("ListRemoteDebugTargets() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user