diff --git a/README.md b/README.md index 3cb2e26..9f02432 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,45 @@ rodney status # Show browser info and active page rodney stop # Shut down Chrome ``` +### Browser extensions + +`--extension` loads a Chrome extension at startup. It works in headless mode as +well as with `--show`: + +```bash +# An unpacked extension directory +rodney start --extension ./my-extension + +# A packed .crx or .zip, which is unpacked into the session directory +rodney start --extension ./my-extension.crx + +# Repeat the flag to load more than one +rodney start --extension ./one --extension ./two +``` + +`rodney extensions` lists what was loaded, including the ID Chrome assigned to +each one, which you need to reach pages served by the extension: + +```bash +rodney extensions +# ldmakemplfmadpiihagnajidjbhnjlcm My Extension 1.0.0 /path/to/my-extension + +rodney open chrome-extension://ldmakemplfmadpiihagnajidjbhnjlcm/popup.html +rodney screenshot popup.png +``` + +Notes: + +- Extensions run in Chrome's [new headless + mode](https://developer.chrome.com/docs/chromium/new-headless), which rodney + switches to automatically when `--extension` is used — the old headless mode + cannot run extensions at all. +- Chrome only loads *unpacked* extensions from the command line, so a `.crx` is + unpacked before being loaded. It therefore gets an ID derived from its path on + disk rather than the ID it would have when installed from the Web Store. +- Only the extensions passed to `--extension` are enabled, and they stay loaded + for the lifetime of the session. + ### Navigate ```bash diff --git a/extensions.go b/extensions.go new file mode 100644 index 0000000..1cd50cb --- /dev/null +++ b/extensions.go @@ -0,0 +1,327 @@ +package main + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "unicode/utf16" + + "github.com/go-rod/rod/lib/launcher" +) + +// extensionList collects repeatable --extension flag values. +type extensionList []string + +func (e *extensionList) String() string { return strings.Join(*e, ",") } + +func (e *extensionList) Set(v string) error { + if v == "" { + return fmt.Errorf("--extension requires a path") + } + *e = append(*e, v) + return nil +} + +// configureExtensions points Chrome at the given extensions, returning l +// unchanged when there are none. +// +// Old headless Chrome cannot run extensions at all, so extensions switch to the +// new headless mode: https://developer.chrome.com/docs/chromium/new-headless +// +// New headless is the full browser stack — renderer, GPU, utility services and +// the extension's own service worker — where rodney's --single-process becomes +// dangerous: it collapses all of those into one OS process (measured: 0 child +// processes and 205 threads, versus 10 and 59 without it), so any CHECK failure +// or bad access anywhere takes down the entire browser rather than one renderer. +// It is dropped here only; launches without --extension keep it, preserving the +// screenshot behaviour it was added for in gVisor/container environments. +func configureExtensions(l *launcher.Launcher, headless bool, extensions []extensionInfo) *launcher.Launcher { + if len(extensions) == 0 { + return l + } + dirs := make([]string, len(extensions)) + for i, ext := range extensions { + dirs[i] = ext.Dir + } + l = l.HeadlessNew(headless). + Delete("single-process"). + Set("load-extension", strings.Join(dirs, ",")). + Set("disable-extensions-except", strings.Join(dirs, ",")) + // Chrome 137+ ignores --load-extension unless this feature is turned off. + // Append rather than Set: rod already disables some features by default. + return l.Append("disable-features", "DisableLoadExtensionCommandLineSwitch") +} + +// manifest is the subset of manifest.json we care about. +type manifest struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// extensionInfo describes one extension that was handed to Chrome. +type extensionInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Dir string `json:"dir"` +} + +// extensionID computes the ID Chrome assigns to an unpacked extension, which is +// derived from the absolute path of its directory: the first 16 bytes of the +// SHA-256 of the path, with each hex digit mapped from 0-f onto a-p. +func extensionID(dir string) string { + sum := sha256.Sum256(extensionIDPathBytes(dir)) + id := make([]byte, 32) + for i, b := range sum[:16] { + id[i*2] = 'a' + (b >> 4) + id[i*2+1] = 'a' + (b & 0x0f) + } + return string(id) +} + +// extensionIDPathBytes reproduces the bytes Chrome hashes for a path. Chrome +// hashes the raw bytes of its native path representation, which is UTF-16 on +// Windows (where it also upper-cases the drive letter) and UTF-8 elsewhere. +// See crx_file::id_util::GenerateIdForPath in the Chromium source. +func extensionIDPathBytes(dir string) []byte { + return extensionIDPathBytesFor(dir, runtime.GOOS) +} + +// extensionIDPathBytesFor is extensionIDPathBytes with the OS passed in, so the +// Windows encoding can be tested from any platform. +func extensionIDPathBytesFor(dir, goos string) []byte { + if goos != "windows" { + return []byte(dir) + } + if len(dir) >= 2 && dir[1] == ':' { + dir = strings.ToUpper(dir[:1]) + dir[1:] + } + var buf bytes.Buffer + for _, unit := range utf16.Encode([]rune(dir)) { + binary.Write(&buf, binary.LittleEndian, unit) + } + return buf.Bytes() +} + +// readManifest loads name and version from an extension directory. +func readManifest(dir string) (manifest, error) { + var m manifest + data, err := os.ReadFile(filepath.Join(dir, "manifest.json")) + if err != nil { + return m, fmt.Errorf("no manifest.json in %s", dir) + } + if err := json.Unmarshal(data, &m); err != nil { + return m, fmt.Errorf("invalid manifest.json in %s: %v", dir, err) + } + return m, nil +} + +// resolveExtension turns a user-supplied path into a directory Chrome can load. +// Directories are used as-is; .crx and .zip archives are unpacked underneath +// unpackRoot. The returned path is absolute and contains a manifest.json. +func resolveExtension(path, unpackRoot string) (extensionInfo, error) { + var info extensionInfo + + abs, err := filepath.Abs(path) + if err != nil { + return info, fmt.Errorf("%s: %v", path, err) + } + st, err := os.Stat(abs) + if err != nil { + return info, fmt.Errorf("%s: no such file or directory", path) + } + + dir := abs + if !st.IsDir() { + switch strings.ToLower(filepath.Ext(abs)) { + case ".crx", ".zip": + dir = filepath.Join(unpackRoot, unpackDirName(abs)) + if err := unpackExtension(abs, dir); err != nil { + return info, fmt.Errorf("%s: %v", path, err) + } + default: + return info, fmt.Errorf("%s: expected a directory or a .crx/.zip archive", path) + } + } + + // Chrome resolves symlinks before deriving the extension ID from the path + // (on macOS /tmp/x is really /private/tmp/x), so resolve them here too or + // the id we report back would not be the one Chrome uses. + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + + // Chrome separates --load-extension paths with commas, so a path containing + // one would silently be split into two bogus paths. + if strings.Contains(dir, ",") { + return info, fmt.Errorf("%s: extension paths cannot contain commas", path) + } + + m, err := readManifest(dir) + if err != nil { + return info, err + } + return extensionInfo{ID: extensionID(dir), Name: m.Name, Version: m.Version, Dir: dir}, nil +} + +// unpackDirName picks the directory an archive is unpacked into. The archive's +// own name is only a readable prefix: names like "...zip" or ".zip" reduce to +// "..", "." or "", which would escape the unpack root and get deleted, so the +// full path is hashed to guarantee a distinct, well-formed directory name. +func unpackDirName(archivePath string) string { + sum := sha256.Sum256([]byte(archivePath)) + suffix := hex.EncodeToString(sum[:4]) + + name := strings.TrimSuffix(filepath.Base(archivePath), filepath.Ext(archivePath)) + name = strings.Map(func(r rune) rune { + if r == '.' || r == '-' || r == '_' || + (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + return r + } + return '-' + }, name) + name = strings.Trim(name, ".-") + if name == "" { + return suffix + } + return name + "-" + suffix +} + +// unpackExtension extracts a .crx or .zip archive into dest, which is replaced +// if it already exists. If the archive wraps everything in a single top-level +// directory, dest is rewritten to point at that directory instead. +func unpackExtension(src, dest string) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + + size, err := f.Seek(0, io.SeekEnd) + if err != nil { + return err + } + offset, err := crxZipOffset(f, size) + if err != nil { + return err + } + zr, err := zip.NewReader(io.NewSectionReader(f, offset, size-offset), size-offset) + if err != nil { + return fmt.Errorf("not a readable crx/zip archive: %v", err) + } + + if err := os.RemoveAll(dest); err != nil { + return err + } + for _, zf := range zr.File { + if err := extractZipEntry(zf, dest); err != nil { + return err + } + } + + if _, err := os.Stat(filepath.Join(dest, "manifest.json")); err == nil { + return nil + } + // Some archives nest the extension one level down. + entries, err := os.ReadDir(dest) + if err != nil { + return err + } + for _, e := range entries { + if !e.IsDir() { + continue + } + nested := filepath.Join(dest, e.Name()) + if _, err := os.Stat(filepath.Join(nested, "manifest.json")); err == nil { + return moveDir(nested, dest) + } + } + return fmt.Errorf("archive does not contain a manifest.json") +} + +// crxZipOffset returns the offset of the zip payload within a crx file of the +// given size. Plain zip files (no "Cr24" magic) start at 0. The declared +// lengths are attacker-controlled, so they are widened to uint64 before being +// added and the result is checked against the file size. +func crxZipOffset(r io.ReaderAt, size int64) (int64, error) { + header := make([]byte, 16) + if _, err := r.ReadAt(header, 0); err != nil { + return 0, fmt.Errorf("file is too small to be an extension archive") + } + if string(header[:4]) != "Cr24" { + return 0, nil // plain zip + } + + var offset uint64 + switch version := binary.LittleEndian.Uint32(header[4:8]); version { + case 2: + // magic + version + key length + signature length, then key and signature + keyLen := uint64(binary.LittleEndian.Uint32(header[8:12])) + sigLen := uint64(binary.LittleEndian.Uint32(header[12:16])) + offset = 16 + keyLen + sigLen + case 3: + // magic + version + header length, then the protobuf header + offset = 12 + uint64(binary.LittleEndian.Uint32(header[8:12])) + default: + return 0, fmt.Errorf("unsupported crx version %d", version) + } + + if offset > uint64(size) { + return 0, fmt.Errorf("crx header extends past the end of the file") + } + return int64(offset), nil +} + +// extractZipEntry writes a single zip entry underneath dest, rejecting paths +// that would escape it. +func extractZipEntry(zf *zip.File, dest string) error { + target := filepath.Join(dest, filepath.FromSlash(zf.Name)) + if target == filepath.Clean(dest) { + return nil // an entry for the archive root itself, nothing to write + } + if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) { + return fmt.Errorf("archive entry escapes the destination directory: %s", zf.Name) + } + if zf.FileInfo().IsDir() { + return os.MkdirAll(target, 0755) + } + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + rc, err := zf.Open() + if err != nil { + return err + } + defer rc.Close() + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, rc) + return err +} + +// moveDir replaces dest with the contents of the nested directory src. +func moveDir(src, dest string) error { + tmp := dest + ".unwrap" + if err := os.RemoveAll(tmp); err != nil { + return err + } + if err := os.Rename(src, tmp); err != nil { + return err + } + if err := os.RemoveAll(dest); err != nil { + return err + } + return os.Rename(tmp, dest) +} diff --git a/extensions_test.go b/extensions_test.go new file mode 100644 index 0000000..6582e0c --- /dev/null +++ b/extensions_test.go @@ -0,0 +1,661 @@ +package main + +import ( + "archive/zip" + "bytes" + "encoding/binary" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" +) + +// --- fixtures --- + +const testExtensionManifest = `{ + "manifest_version": 3, + "name": "Rodney Test Extension", + "version": "1.2.3", + "content_scripts": [{"matches": [""], "js": ["content.js"]}] +}` + +// The content script renames the page so a test can prove it ran. +const testExtensionContentScript = `document.title = "extension-was-here";` + +// writeTestExtension creates an unpacked extension directory and returns its path. +func writeTestExtension(t *testing.T, dir string) string { + t.Helper() + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + write := func(name, body string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0644); err != nil { + t.Fatal(err) + } + } + write("manifest.json", testExtensionManifest) + write("content.js", testExtensionContentScript) + return dir +} + +// zipBytes builds an in-memory zip archive from name -> contents. +func zipBytes(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, body := range files { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// crx3Bytes wraps zip payload in a CRX3 container with a dummy header. +func crx3Bytes(t *testing.T, payload []byte) []byte { + t.Helper() + header := []byte("dummy-crx3-header") + var buf bytes.Buffer + buf.WriteString("Cr24") + binary.Write(&buf, binary.LittleEndian, uint32(3)) + binary.Write(&buf, binary.LittleEndian, uint32(len(header))) + buf.Write(header) + buf.Write(payload) + return buf.Bytes() +} + +// crx2Bytes wraps zip payload in a CRX2 container with dummy key and signature. +func crx2Bytes(t *testing.T, payload []byte) []byte { + t.Helper() + key, sig := []byte("dummy-key"), []byte("dummy-signature") + var buf bytes.Buffer + buf.WriteString("Cr24") + binary.Write(&buf, binary.LittleEndian, uint32(2)) + binary.Write(&buf, binary.LittleEndian, uint32(len(key))) + binary.Write(&buf, binary.LittleEndian, uint32(len(sig))) + buf.Write(key) + buf.Write(sig) + buf.Write(payload) + return buf.Bytes() +} + +// --- extensionID --- + +func TestExtensionID_MatchesChromeAlgorithm(t *testing.T) { + // Chrome derives an unpacked extension's ID from its absolute path. This + // expectation was captured by loading an extension from this exact path in + // a real Chrome session and reading back the id it assigned. + got := extensionID("/private/tmp/rodney-test-extension") + want := "kpcblmbemcppaagejgmknhdmdmmnhcml" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestExtensionID_UsesLettersAToP(t *testing.T) { + id := extensionID("/tmp/some-extension") + if len(id) != 32 { + t.Fatalf("expected a 32 character id, got %d: %q", len(id), id) + } + for _, c := range id { + if c < 'a' || c > 'p' { + t.Errorf("id contains out-of-range character %q: %s", c, id) + } + } +} + +// TestExtensionIDPathBytes_Windows pins the Windows encoding, which differs +// from every other platform: Chrome hashes the raw bytes of its native path +// type, which on Windows is UTF-16, and it upper-cases the drive letter first. +func TestExtensionIDPathBytes_Windows(t *testing.T) { + got := extensionIDPathBytesFor(`c:\ext`, "windows") + want := []byte{'C', 0, ':', 0, '\\', 0, 'e', 0, 'x', 0, 't', 0} + if !bytes.Equal(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestExtensionIDPathBytes_Posix(t *testing.T) { + got := extensionIDPathBytesFor("/tmp/ext", "linux") + if !bytes.Equal(got, []byte("/tmp/ext")) { + t.Errorf("got %v, want the plain path bytes", got) + } +} + +func TestExtensionID_DiffersByPath(t *testing.T) { + if extensionID("/tmp/a") == extensionID("/tmp/b") { + t.Error("different paths should produce different ids") + } +} + +// --- parseStartArgs --- + +func TestParseStartArgs_NoExtensions(t *testing.T) { + opts, err := parseStartArgs([]string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts.extensions) != 0 { + t.Errorf("expected no extensions, got %v", opts.extensions) + } +} + +func TestParseStartArgs_SingleExtension(t *testing.T) { + opts, err := parseStartArgs([]string{"--extension", "/tmp/ext"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts.extensions) != 1 || opts.extensions[0] != "/tmp/ext" { + t.Errorf("got %v, want [/tmp/ext]", opts.extensions) + } +} + +func TestParseStartArgs_RepeatedExtensions(t *testing.T) { + opts, err := parseStartArgs([]string{"--extension", "/tmp/one", "--extension", "/tmp/two"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts.extensions) != 2 || opts.extensions[0] != "/tmp/one" || opts.extensions[1] != "/tmp/two" { + t.Errorf("got %v, want [/tmp/one /tmp/two]", opts.extensions) + } +} + +func TestParseStartArgs_ExtensionWithOtherFlags(t *testing.T) { + opts, err := parseStartArgs([]string{"--show", "--extension", "/tmp/ext", "-k"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.headless { + t.Error("expected headless=false when --show is passed") + } + if !opts.ignoreCertErrors { + t.Error("expected insecure=true when -k is passed") + } + if len(opts.extensions) != 1 { + t.Errorf("got %v, want one extension", opts.extensions) + } +} + +// --- resolveExtension --- + +func TestResolveExtension_Directory(t *testing.T) { + dir := writeTestExtension(t, filepath.Join(t.TempDir(), "ext")) + dir, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + + info, err := resolveExtension(dir, t.TempDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Name != "Rodney Test Extension" { + t.Errorf("got name %q", info.Name) + } + if info.Version != "1.2.3" { + t.Errorf("got version %q", info.Version) + } + if info.Dir != dir { + t.Errorf("got dir %q, want %q", info.Dir, dir) + } + if info.ID != extensionID(dir) { + t.Errorf("got id %q, want %q", info.ID, extensionID(dir)) + } +} + +func TestResolveExtension_RelativeDirectoryBecomesAbsolute(t *testing.T) { + tmp := t.TempDir() + writeTestExtension(t, filepath.Join(tmp, "ext")) + t.Chdir(tmp) + + info, err := resolveExtension("ext", t.TempDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !filepath.IsAbs(info.Dir) { + t.Errorf("expected an absolute path, got %q", info.Dir) + } +} + +func TestResolveExtension_ResolvesSymlinks(t *testing.T) { + // Chrome derives the extension ID from the symlink-free path, so rodney has + // to resolve links or it would report an ID Chrome never uses. + real := writeTestExtension(t, filepath.Join(t.TempDir(), "real")) + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(real, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + info, err := resolveExtension(link, t.TempDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want, err := filepath.EvalSymlinks(real) + if err != nil { + t.Fatal(err) + } + if info.Dir != want { + t.Errorf("got dir %q, want %q", info.Dir, want) + } +} + +func TestResolveExtension_MissingPath(t *testing.T) { + _, err := resolveExtension(filepath.Join(t.TempDir(), "nope"), t.TempDir()) + if err == nil { + t.Fatal("expected an error for a missing path") + } + if !strings.Contains(err.Error(), "no such file") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestResolveExtension_DirectoryWithoutManifest(t *testing.T) { + _, err := resolveExtension(t.TempDir(), t.TempDir()) + if err == nil { + t.Fatal("expected an error for a directory with no manifest.json") + } + if !strings.Contains(err.Error(), "manifest.json") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestResolveExtension_UnsupportedFileType(t *testing.T) { + path := filepath.Join(t.TempDir(), "ext.tar.gz") + if err := os.WriteFile(path, []byte("nope"), 0644); err != nil { + t.Fatal(err) + } + _, err := resolveExtension(path, t.TempDir()) + if err == nil { + t.Fatal("expected an error for an unsupported archive type") + } + if !strings.Contains(err.Error(), ".crx/.zip") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestResolveExtension_Zip(t *testing.T) { + path := filepath.Join(t.TempDir(), "packed.zip") + payload := zipBytes(t, map[string]string{ + "manifest.json": testExtensionManifest, + "content.js": testExtensionContentScript, + }) + if err := os.WriteFile(path, payload, 0644); err != nil { + t.Fatal(err) + } + + info, err := resolveExtension(path, t.TempDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Name != "Rodney Test Extension" { + t.Errorf("got name %q", info.Name) + } + if _, err := os.Stat(filepath.Join(info.Dir, "content.js")); err != nil { + t.Errorf("content.js was not extracted: %v", err) + } +} + +func TestResolveExtension_CRX3(t *testing.T) { + path := filepath.Join(t.TempDir(), "packed.crx") + payload := crx3Bytes(t, zipBytes(t, map[string]string{ + "manifest.json": testExtensionManifest, + "content.js": testExtensionContentScript, + })) + if err := os.WriteFile(path, payload, 0644); err != nil { + t.Fatal(err) + } + + info, err := resolveExtension(path, t.TempDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Version != "1.2.3" { + t.Errorf("got version %q", info.Version) + } +} + +func TestResolveExtension_CRX2(t *testing.T) { + path := filepath.Join(t.TempDir(), "packed.crx") + payload := crx2Bytes(t, zipBytes(t, map[string]string{ + "manifest.json": testExtensionManifest, + })) + if err := os.WriteFile(path, payload, 0644); err != nil { + t.Fatal(err) + } + + if _, err := resolveExtension(path, t.TempDir()); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolveExtension_ArchiveWithNestedDirectory(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested.zip") + payload := zipBytes(t, map[string]string{ + "my-extension/manifest.json": testExtensionManifest, + "my-extension/content.js": testExtensionContentScript, + }) + if err := os.WriteFile(path, payload, 0644); err != nil { + t.Fatal(err) + } + + info, err := resolveExtension(path, t.TempDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(filepath.Join(info.Dir, "manifest.json")); err != nil { + t.Errorf("manifest.json should sit at the root of the resolved dir: %v", err) + } +} + +func TestResolveExtension_ArchiveWithoutManifest(t *testing.T) { + path := filepath.Join(t.TempDir(), "empty.zip") + if err := os.WriteFile(path, zipBytes(t, map[string]string{"readme.txt": "hi"}), 0644); err != nil { + t.Fatal(err) + } + _, err := resolveExtension(path, t.TempDir()) + if err == nil { + t.Fatal("expected an error for an archive with no manifest.json") + } +} + +func TestResolveExtension_UnsupportedCRXVersion(t *testing.T) { + var buf bytes.Buffer + buf.WriteString("Cr24") + binary.Write(&buf, binary.LittleEndian, uint32(99)) + buf.Write(make([]byte, 8)) + path := filepath.Join(t.TempDir(), "future.crx") + if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil { + t.Fatal(err) + } + + _, err := resolveExtension(path, t.TempDir()) + if err == nil { + t.Fatal("expected an error for an unknown crx version") + } + if !strings.Contains(err.Error(), "crx version 99") { + t.Errorf("unexpected error: %v", err) + } +} + +// TestUnpackDirName_StaysInsideUnpackRoot guards a nasty case: an archive named +// "...zip" has extension ".zip" and stem "..", which naively joined onto the +// unpack root escapes it and points at the session directory, which unpacking +// then deletes. +func TestUnpackDirName_StaysInsideUnpackRoot(t *testing.T) { + root := "/state/extensions" + for _, name := range []string{"...zip", "..zip", ".zip", ".crx", "..crx", "a/b/../../evil.zip", "ext.zip"} { + dir := filepath.Join(root, unpackDirName(name)) + if filepath.Dir(dir) != root { + t.Errorf("archive %q unpacks to %q, which is outside %q", name, dir, root) + } + } +} + +func TestUnpackDirName_DistinctForSameBasename(t *testing.T) { + if unpackDirName("/one/ext.zip") == unpackDirName("/two/ext.zip") { + t.Error("archives with the same basename must not share an unpack directory") + } +} + +func TestUnpackDirName_StableForSamePath(t *testing.T) { + if unpackDirName("/one/ext.zip") != unpackDirName("/one/ext.zip") { + t.Error("the same archive path must map to the same unpack directory") + } +} + +// TestResolveExtension_ArchiveDoesNotEscapeUnpackRoot is the end-to-end version +// of the check above: it proves a hostile archive name cannot delete the +// session directory that contains the unpack root. +func TestResolveExtension_ArchiveDoesNotEscapeUnpackRoot(t *testing.T) { + stateDir := t.TempDir() + unpackRoot := filepath.Join(stateDir, "extensions") + if err := os.MkdirAll(unpackRoot, 0755); err != nil { + t.Fatal(err) + } + canary := filepath.Join(stateDir, "state.json") + if err := os.WriteFile(canary, []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + archive := filepath.Join(t.TempDir(), "...zip") + payload := zipBytes(t, map[string]string{"manifest.json": testExtensionManifest}) + if err := os.WriteFile(archive, payload, 0644); err != nil { + t.Fatal(err) + } + + if _, err := resolveExtension(archive, unpackRoot); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(canary); err != nil { + t.Errorf("unpacking deleted the session directory: %v", err) + } +} + +func TestResolveExtension_SameBasenameArchivesStaySeparate(t *testing.T) { + unpackRoot := t.TempDir() + newArchive := func(dir, name, version string) string { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "ext.zip") + manifest := `{"manifest_version": 3, "name": "` + name + `", "version": "` + version + `"}` + if err := os.WriteFile(path, zipBytes(t, map[string]string{"manifest.json": manifest}), 0644); err != nil { + t.Fatal(err) + } + return path + } + + tmp := t.TempDir() + first, err := resolveExtension(newArchive(filepath.Join(tmp, "a"), "First", "1.0.0"), unpackRoot) + if err != nil { + t.Fatal(err) + } + second, err := resolveExtension(newArchive(filepath.Join(tmp, "b"), "Second", "2.0.0"), unpackRoot) + if err != nil { + t.Fatal(err) + } + + if first.Dir == second.Dir { + t.Fatalf("both archives unpacked to %q", first.Dir) + } + if first.ID == second.ID { + t.Error("both extensions were given the same id") + } + // The first extension must survive unpacking the second. + if got, err := readManifest(first.Dir); err != nil || got.Name != "First" { + t.Errorf("first extension was clobbered: name=%q err=%v", got.Name, err) + } +} + +func TestCrxZipOffset_RejectsOverflowingLengths(t *testing.T) { + // A CRX2 header claiming a key length near uint32 max used to wrap around + // to a small offset instead of being rejected. + var crx2 bytes.Buffer + crx2.WriteString("Cr24") + binary.Write(&crx2, binary.LittleEndian, uint32(2)) + binary.Write(&crx2, binary.LittleEndian, uint32(0xFFFFFFF0)) + binary.Write(&crx2, binary.LittleEndian, uint32(0xFFFFFFF0)) + crx2.Write(make([]byte, 32)) + + var crx3 bytes.Buffer + crx3.WriteString("Cr24") + binary.Write(&crx3, binary.LittleEndian, uint32(3)) + binary.Write(&crx3, binary.LittleEndian, uint32(0xFFFFFFFC)) + crx3.Write(make([]byte, 32)) + + for name, payload := range map[string][]byte{"crx2": crx2.Bytes(), "crx3": crx3.Bytes()} { + r := bytes.NewReader(payload) + if _, err := crxZipOffset(r, int64(len(payload))); err == nil { + t.Errorf("%s: expected an error for a header longer than the file", name) + } + } +} + +func TestUnpackExtension_AcceptsArchiveRootEntry(t *testing.T) { + // Some zip writers emit a "./" entry, which must not be mistaken for an + // attempt to escape the destination directory. + path := filepath.Join(t.TempDir(), "rooted.zip") + payload := zipBytes(t, map[string]string{ + "./": "", + "manifest.json": testExtensionManifest, + }) + if err := os.WriteFile(path, payload, 0644); err != nil { + t.Fatal(err) + } + + if _, err := resolveExtension(path, t.TempDir()); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestUnpackExtension_RejectsPathTraversal(t *testing.T) { + path := filepath.Join(t.TempDir(), "evil.zip") + payload := zipBytes(t, map[string]string{"../escaped.txt": "pwned"}) + if err := os.WriteFile(path, payload, 0644); err != nil { + t.Fatal(err) + } + + dest := filepath.Join(t.TempDir(), "unpacked") + err := unpackExtension(path, dest) + if err == nil { + t.Fatal("expected an error for an archive entry escaping the destination") + } + if !strings.Contains(err.Error(), "escapes") { + t.Errorf("unexpected error: %v", err) + } +} + +// --- end to end --- + +// TestExtension_LoadsInHeadlessChrome is the test that matters: it launches a +// headless browser the same way "rodney start --extension" does and checks the +// extension's content script actually ran on a page. +// baseLauncher mirrors the flags cmdStart sets before configureExtensions runs. +func baseLauncher() *launcher.Launcher { + return launcher.New(). + Set("no-sandbox"). + Set("disable-gpu"). + Set("single-process"). + Leakless(false) +} + +// headlessMode reports the --headless value: "" for old headless (the flag is +// present but valueless), "new" for new headless, "off" when it is absent. +// launcher.Get panics on a valueless flag, so read the values directly. +func headlessMode(l *launcher.Launcher) string { + values, ok := l.GetFlags("headless") + if !ok { + return "off" + } + if len(values) == 0 { + return "" + } + return values[0] +} + +func TestConfigureExtensions_NoExtensionsLeavesLauncherAlone(t *testing.T) { + l := configureExtensions(baseLauncher().Headless(true), true, nil) + + if !l.Has("single-process") { + t.Error("--single-process was dropped for a launch with no extensions") + } + if got := headlessMode(l); got != "" { + t.Errorf("headless = %q, want %q (old headless) when no extensions are loaded", got, "") + } + if l.Has("load-extension") { + t.Error("--load-extension set with no extensions") + } +} + +// New headless brings up the full browser stack (renderer, GPU, utility services, +// the extension service worker). --single-process collapses all of that into one +// OS process where a single CHECK failure kills the whole browser, so extensions +// must drop it. +func TestConfigureExtensions_DropsSingleProcess(t *testing.T) { + l := configureExtensions(baseLauncher().Headless(true), true, + []extensionInfo{{Dir: "/tmp/ext"}}) + + if l.Has("single-process") { + t.Error("--single-process survived; new headless + extensions must not run single-process") + } +} + +func TestConfigureExtensions_SelectsNewHeadless(t *testing.T) { + l := configureExtensions(baseLauncher().Headless(true), true, + []extensionInfo{{Dir: "/tmp/ext"}}) + + if got := headlessMode(l); got != "new" { + t.Errorf("headless = %q, want %q; old headless cannot run extensions", got, "new") + } +} + +// --show must stay a real visible window, not new headless. +func TestConfigureExtensions_HeadedStaysHeaded(t *testing.T) { + l := configureExtensions(baseLauncher().Headless(false), false, + []extensionInfo{{Dir: "/tmp/ext"}}) + + if got := headlessMode(l); got != "off" { + t.Errorf("headless = %q, want it absent, for a --show launch", got) + } + if l.Has("single-process") { + t.Error("--single-process survived; extensions must not run single-process") + } +} + +func TestConfigureExtensions_JoinsDirsWithCommas(t *testing.T) { + l := configureExtensions(baseLauncher().Headless(true), true, + []extensionInfo{{Dir: "/tmp/one"}, {Dir: "/tmp/two"}}) + + const want = "/tmp/one,/tmp/two" + if got := l.Get("load-extension"); got != want { + t.Errorf("load-extension = %q, want %q", got, want) + } + if got := l.Get("disable-extensions-except"); got != want { + t.Errorf("disable-extensions-except = %q, want %q", got, want) + } +} + +// rod disables some features by default; the extension switch must be appended +// to those rather than replacing them. +func TestConfigureExtensions_AppendsToExistingDisableFeatures(t *testing.T) { + base := baseLauncher().Headless(true).Set("disable-features", "TranslateUI") + l := configureExtensions(base, true, []extensionInfo{{Dir: "/tmp/ext"}}) + + flags, _ := l.GetFlags("disable-features") + got := strings.Join(flags, ",") + const want = "TranslateUI,DisableLoadExtensionCommandLineSwitch" + if got != want { + t.Errorf("disable-features = %q, want %q", got, want) + } +} + +func TestExtension_LoadsInHeadlessChrome(t *testing.T) { + dir := writeTestExtension(t, filepath.Join(t.TempDir(), "ext")) + + l := configureExtensions(baseLauncher().Headless(true), true, + []extensionInfo{{Dir: dir}}) + + if bin := os.Getenv("ROD_CHROME_BIN"); bin != "" { + l = l.Bin(bin) + } + + browser := rod.New().ControlURL(l.MustLaunch()).MustConnect() + defer browser.MustClose() + + page := browser.MustPage(env.server.URL + "/") + page.MustWaitLoad() + + if got := page.MustInfo().Title; got != "extension-was-here" { + t.Errorf("content script did not run: page title is %q, want %q", got, "extension-was-here") + } +} diff --git a/help.txt b/help.txt index 79bac7f..62ec986 100644 --- a/help.txt +++ b/help.txt @@ -1,10 +1,14 @@ rodney - Chrome automation from the command line Browser lifecycle: - rodney start [--show] [--insecure | -k] Launch Chrome (headless by default, --show for visible) + rodney start [--show] [--insecure | -k] [--extension PATH] + Launch Chrome (headless by default, --show for visible) + --extension loads an unpacked directory or a + .crx/.zip archive; repeat for several rodney connect Connect to existing Chrome on remote debug port rodney stop Shut down Chrome rodney status Show browser status + rodney extensions List extensions loaded into this session Navigation: rodney open Navigate to URL diff --git a/main.go b/main.go index a5188db..c549596 100644 --- a/main.go +++ b/main.go @@ -85,6 +85,8 @@ type State struct { DataDir string `json:"data_dir"` ProxyPID int `json:"proxy_pid,omitempty"` // PID of auth proxy helper ProxyPort int `json:"proxy_port,omitempty"` // local port of auth proxy + + Extensions []extensionInfo `json:"extensions,omitempty"` // extensions passed to --load-extension } func stateDir() string { @@ -215,6 +217,8 @@ func main() { cmdStop(args) case "status": cmdStatus(args) + case "extensions": + cmdExtensions(args) case "open": cmdOpen(args) case "back": @@ -336,30 +340,44 @@ func withPage() (*State, *rod.Browser, *rod.Page) { // --- Commands --- +const startUsage = "usage: rodney start [--show] [--insecure] [--extension PATH]" + +// startOptions holds the parsed flags for the "start" command. +type startOptions struct { + ignoreCertErrors bool + headless bool + extensions []string +} + // parseStartArgs parses the flags for the "start" command. -// Returns ignoreCertErrors, headless, and an error for unknown flags. -func parseStartArgs(args []string) (ignoreCertErrors bool, headless bool, err error) { +func parseStartArgs(args []string) (startOptions, error) { + opts := startOptions{headless: true} + var extensions extensionList + fs := flag.NewFlagSet("start", flag.ContinueOnError) fs.SetOutput(io.Discard) - fs.BoolVar(&ignoreCertErrors, "insecure", false, "") - fs.BoolVar(&ignoreCertErrors, "k", false, "") + fs.BoolVar(&opts.ignoreCertErrors, "insecure", false, "") + fs.BoolVar(&opts.ignoreCertErrors, "k", false, "") + fs.Var(&extensions, "extension", "") show := fs.Bool("show", false, "") if parseErr := fs.Parse(args); parseErr != nil { - return false, true, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure]", findUnknownFlag(args, fs)) + return startOptions{headless: true}, fmt.Errorf("unknown flag: %s\n%s", findUnknownFlag(args, fs), startUsage) } if fs.NArg() > 0 { - return false, true, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure]", fs.Arg(0)) + return startOptions{headless: true}, fmt.Errorf("unknown flag: %s\n%s", fs.Arg(0), startUsage) } - headless = !*show - return ignoreCertErrors, headless, nil + opts.headless = !*show + opts.extensions = extensions + return opts, nil } func cmdStart(args []string) { - ignoreCertErrors, headless, err := parseStartArgs(args) + opts, err := parseStartArgs(args) if err != nil { fatal("%s", err) } + ignoreCertErrors, headless := opts.ignoreCertErrors, opts.headless // Check if already running if s, err := loadState(); err == nil { @@ -374,6 +392,8 @@ func cmdStart(args []string) { dataDir := filepath.Join(stateDir(), "chrome-data") os.MkdirAll(dataDir, 0755) + extensions := loadExtensions(opts.extensions) + l := launcher.New(). Set("no-sandbox"). Set("disable-gpu"). @@ -388,6 +408,8 @@ func cmdStart(args []string) { l = l.Delete("no-startup-window") } + l = configureExtensions(l, headless, extensions) + if bin := os.Getenv("ROD_CHROME_BIN"); bin != "" { l = l.Bin(bin) } @@ -441,6 +463,7 @@ func cmdStart(args []string) { DataDir: dataDir, ProxyPID: proxyPID, ProxyPort: proxyPort, + Extensions: extensions, } if err := saveState(state); err != nil { @@ -449,6 +472,43 @@ func cmdStart(args []string) { fmt.Printf("Chrome started (PID %d)\n", pid) fmt.Printf("Debug URL: %s\n", debugURL) + for _, ext := range extensions { + fmt.Printf("Extension loaded: %s (%s)\n", ext.Name, ext.ID) + } +} + +// loadExtensions resolves --extension paths into directories Chrome can load, +// unpacking .crx/.zip archives into the session directory as needed. +func loadExtensions(paths []string) []extensionInfo { + if len(paths) == 0 { + return nil + } + unpackRoot := filepath.Join(stateDir(), "extensions") + if err := os.MkdirAll(unpackRoot, 0755); err != nil { + fatal("failed to create extension directory: %v", err) + } + extensions := make([]extensionInfo, 0, len(paths)) + for _, path := range paths { + info, err := resolveExtension(path, unpackRoot) + if err != nil { + fatal("%v", err) + } + extensions = append(extensions, info) + } + return extensions +} + +func cmdExtensions(args []string) { + if len(args) > 0 { + fatal("usage: rodney extensions") + } + s, err := loadState() + if err != nil { + fatal("%v", err) + } + for _, ext := range s.Extensions { + fmt.Printf("%s %s %s %s\n", ext.ID, ext.Name, ext.Version, ext.Dir) + } } func cmdConnect(args []string) { @@ -542,6 +602,9 @@ func cmdStatus(args []string) { fmt.Printf("Debug URL: %s\n", s.DebugURL) fmt.Printf("Pages: %d\n", len(pages)) fmt.Printf("Active page: %d\n", s.ActivePage) + for _, ext := range s.Extensions { + fmt.Printf("Extension: %s (%s)\n", ext.Name, ext.ID) + } if page, err := getActivePage(browser, s); err == nil { info, _ := page.Info() if info != nil { diff --git a/main_test.go b/main_test.go index 1753da9..e55bd2d 100644 --- a/main_test.go +++ b/main_test.go @@ -1079,69 +1079,69 @@ func TestFormatAssertFail_EqualityWithMessage(t *testing.T) { // ===================== func TestParseStartArgs_NoFlags(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{}) + opts, err := parseStartArgs([]string{}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if insecure { + if opts.ignoreCertErrors { t.Error("expected insecure=false with no flags") } - if !headless { + if !opts.headless { t.Error("expected headless=true with no flags") } } func TestParseStartArgs_ShowFlag(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{"--show"}) + opts, err := parseStartArgs([]string{"--show"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if insecure { + if opts.ignoreCertErrors { t.Error("expected insecure=false") } - if headless { + if opts.headless { t.Error("expected headless=false when --show is passed") } } func TestParseStartArgs_InsecureFlag(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{"--insecure"}) + opts, err := parseStartArgs([]string{"--insecure"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if !insecure { + if !opts.ignoreCertErrors { t.Error("expected insecure=true when --insecure is passed") } - if !headless { + if !opts.headless { t.Error("expected headless=true when only --insecure is passed") } } func TestParseStartArgs_InsecureShortFlag(t *testing.T) { - insecure, _, err := parseStartArgs([]string{"-k"}) + opts, err := parseStartArgs([]string{"-k"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if !insecure { + if !opts.ignoreCertErrors { t.Error("expected insecure=true when -k is passed") } } func TestParseStartArgs_ShowAndInsecure(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{"--show", "--insecure"}) + opts, err := parseStartArgs([]string{"--show", "--insecure"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if !insecure { + if !opts.ignoreCertErrors { t.Error("expected insecure=true") } - if headless { + if opts.headless { t.Error("expected headless=false when --show is passed") } } func TestParseStartArgs_UnknownFlag(t *testing.T) { - _, _, err := parseStartArgs([]string{"--bogus"}) + _, err := parseStartArgs([]string{"--bogus"}) if err == nil { t.Fatal("expected error for unknown flag --bogus") }