browser-use 开源项目怎么让 Agent 登你的账号:敏感数据占位、域名限制与登录态存储三件套

2026-07-30

本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。

这三块功能里没有任何一块能单独构成”安全”,它们是互相兜底的:占位机制只保证密码不进模型上下文,管不住 Agent 把它输到哪个站点;域名限制只管导航目标,管不住已经贴在页面上的凭据被表单偷走;登录态存储让你压根不用给密码,代价是一份明文 JSON 躺在磁盘上。只上其中一块,你得到的是错觉。

先说清读者画像。你手上有一个自动化需求:让模型自己开浏览器,登进某个后台,把报表拉出来。你已经知道这类工具会驱动真实 Chrome、带着真实登录态操作真实账号,所以卡在”敢不敢把凭据交给它”这一步。本文只回答这一步,逐个翻代码。

站内另有三篇讲上层原则:日志里的敏感信息怎么处理 讲的是可观测性侧的脱敏,Agent 最小权限设计Agent 权限给太大会怎样 讲的是权限边界的通用方法论;本文不重复原则,只做一件事——把 browser-use 这个具体项目的实现摊开,看它的原则落地到哪一行、断在哪一行。

一、三块拼图各自守在哪一层

先给全景,后面每节展开一块。表里的路径都是仓库里的真实位置,你可以直接打开对照。

组成部分它负责什么对应仓库位置你什么时候会碰到它
敏感数据占位替换模型只看到占位名,实际值在动作执行前才注入参数browser_use/tools/registry/service.py_replace_sensitive_data传了 sensitive_data 之后的每一次 input 动作
回填内容遮蔽把真实值从发回模型的状态消息里换成占位标签browser_use/agent/message_manager/service.py_filter_sensitive_databrowser_use/utils.pyredact_sensitive_string页面回读、历史压缩、save_history 落盘时
导航门禁拦住去不该去的域名,拦不住的就把标签页关掉browser_use/browser/watchdogs/security_watchdog.py配了 allowed_domains 之后每次跳转与新开标签页
门禁的配置面声明白名单、黑名单、是否禁止 IP 直连browser_use/browser/profile.pyallowed_domainsprohibited_domainsblock_ip_addresses构造 BrowserProfile
登录态落盘与回灌周期性把 cookie 与 storage 存成 JSON,下次启动自动灌回去browser_use/browser/watchdogs/storage_state_watchdog.py设了 storage_state 路径之后的整个会话
从真实 Chrome 导出登录态一次性把你本机浏览器的会话导成文件examples/browser/save_cookies.py你不想让 Agent 走登录流程的时候
启动期配置体检发现配置组合不安全时打告警browser_use/agent/service.py 构造函数、profile.py 的多个 model_validator每次构造 AgentBrowserProfile

看这张表能得出一个判断:这个项目把安全做成了若干独立的检查点,而不是一个总开关。好处是每块都能单独关掉、单独替换;坏处是漏配一块,其它块不会替你报错拦停,多数情况只是日志里多一行告警。

二、敏感数据占位:模型看到名字,浏览器里落的是值

examples/features/sensitive_data.py 里给了两种写法。旧写法是平铺的键值对,注释直说”模型会看到 x_name 和 x_password,但永远看不到真实值”。新写法按域名分组:

company_credentials: dict[str, str] = {'telephone': '9123456789', 'email': 'user@example.com', 'name': 'John Doe'}

sensitive_data: dict[str, str | dict[str, str]] = {
	'httpbin.org': company_credentials,
	# 'https://*.example-staging.com': company_credentials,
	# 'http*://test.example.com': company_credentials,
}

agent = Agent(task=task, llm=llm, sensitive_data=sensitive_data)

机制分两个方向走。

下行方向(发给模型的东西):message_manager 里的 _get_sensitive_data_description 只把键名收集起来,拼成一段 SENSITIVE DATA - Use these placeholders for secure input: 的说明,附一句要求模型把占位名包在 <secret> 标签里。注意这里已经带了域名判断——对分组写法,只有当前页面 URL 匹配上那个域名模式,对应的键名才会出现在说明里。模型在错误的站点上,连占位名都拿不到。

上行方向(执行动作时):_replace_sensitive_data 拿到校验过的参数模型,递归遍历所有字符串,把 <secret>xxx</secret> 替换成真实值。域名判断在这里再做一次:

for domain_or_key, content in sensitive_data.items():
	if isinstance(content, dict):
		if current_url and not is_new_tab_page(current_url):
			if match_url_with_domain_pattern(current_url, domain_or_key):
				applicable_secrets.update(content)
	else:
		# Old format: {key: value}, expose to all domains (only allowed for legacy reasons)
		applicable_secrets[domain_or_key] = content

那行注释是重点:平铺写法对所有域名生效,代码里明说是为兼容遗留才保留的。你要是图省事用平铺,域名隔离这一层直接归零。

两个附带设计值得知道。一是键名以 bu_2fa_code 结尾时,值被当成 TOTP 密钥,用 pyotp 现算一个 6 位码填进去——也就是说二次验证不是绕过,是你把种子交给了它。二是除了标签形式,参数值本身恰好等于某个占位名时也会被替换成真实值(代码注释说是兜住”模型忘了加标签”的情况)。这条兜底有副作用,后面避坑清单里单独说。

值回流的一侧靠 _filter_sensitive_data:状态消息在写入历史前,用 redact_sensitive_string 把真实值换回 <secret>键名</secret>,替换顺序按值的长度从长到短,避免短值先命中造成部分泄漏。input 动作的执行结果也被特殊处理过——_detect_sensitive_key_name 反查这次输入的是哪个键,结果文案写成 Typed <键名> 而不是把明文回显进历史。Agent.save_history 落盘时同样把 sensitive_data 传下去做遮蔽。

要留意 _set_message_with_type 里的分工注释:系统消息不过滤(里面只有指令和占位名),状态消息才过滤(里面有替换后的真实值)。这个划分本身合理,但也意味着遮蔽是”按已知值做字符串替换”。页面把你的手机号重排成带分隔符的样子再回显,字符串就对不上了,遮蔽会静默失手。

三、域名限制:把”能去哪”钉死,占位才有意义

SecurityWatchdog 挂在三个事件上:NavigateToUrlEventNavigationCompleteEventTabCreatedEvent。第一个是导航前拦截,直接抛异常中断;第二个是导航完成后复查,专门抓重定向落到黑名单的情况,处理方式是发一条 NavigationBlockedBrowserErrorEvent,然后把页面导到 about:blank——注释写明这是为了保住会话,让 Agent 看到错误后继续干别的;第三个是新标签页带着不允许的 URL 出现时,发 TabCreationBlocked 并尝试关掉这个标签页。

匹配逻辑在 _is_url_match,几条行为需要记住:

if pattern.startswith('*.'):
	# Pattern like *.example.com should match subdomains and main domain
	domain_part = pattern[2:]  # Remove *.
	if host == domain_part or host.endswith('.' + domain_part):
		# Only match http/https URLs for domain-only patterns
		if scheme in ['http', 'https']:
			return True

*.example.com 同时命中子域和主域,这一点项目自己也会打告警提醒(_log_glob_warning)。纯域名写法只对 http/https 生效。精确写法里带 :// 的按前缀匹配整条 URL,不带的按主机名比对,且单点域名会额外补一个 www. 变体(_is_root_domain 的判断简单直接:域名里恰好一个点才算根域)。

黑白名单的优先级是硬编码的:_is_url_allowed 里只要 allowed_domains 非空,就在白名单分支里直接 return,prohibited_domains 那段代码根本执行不到。字段描述里也写着白名单优先。所以”白名单放宽一点、再用黑名单挖掉几个”这种写法在这里不成立。

block_ip_addresses 单独一档,默认关。打开后 _is_ip_address 会做一轮相当认真的规范化——URL 解码、NFKC 归一化、把全角句点换成半角、ipaddress 解析失败再用 socket.inet_aton 兜住十进制/十六进制/八进制/短格式的非标准 IPv4 写法。函数注释说这是为了对齐浏览器自己的主机名规范化,别让另类写法绕过限制。这一段代码质量能说明设计意图:域名门禁是被当成安全边界写的,不是当成便利功能写的。

配置层还有一个静默陷阱。profile.pyDOMAIN_OPTIMIZATION_THRESHOLD = 100,域名列表长度到 100 就被转成 set 换 O(1) 查表,通配符匹配随之失效——会打一行 warning,但不会报错。你要是把白名单从 99 条加到 101 条,*. 那些模式当天就悄悄不生效了。

最后是构造 Agent 时的体检。传了 sensitive_data 却没配 allowed_domains,日志里会出现一条很直白的告警:Agent 访问到恶意站点、遇上提示注入攻击时敏感数据可能外泄。用了分组写法的话,还会逐个检查分组里的域名模式是否被 allowed_domains 覆盖,没覆盖就告警说凭据可能被用在预期外的域名上。这些都只是告警——程序照跑。提示注入这条攻击面本身的防法,见 提示注入怎么防,本文不展开。

四、登录态存储:不给密码的那条路

还有一条完全不碰密码的路子:把已登录的会话状态搬过来。examples/browser/save_cookies.py 展示了导出侧,逻辑很短——列出本机 Chrome 的配置文件让你挑,启动,导出,停止:

browser = Browser.from_system_chrome(profile_directory=profile)

await browser.start()
await browser.export_storage_state('storage_state.json')
await browser.stop()

导入与维护侧由 StorageStateWatchdog 负责。BrowserConnectedEvent 一到,它启动监控任务并主动派发一个 LoadStorageStateEvent 把文件灌回浏览器;BrowserStopEvent 时停掉监控。默认参数是 auto_save_interval=30.0save_on_change=True,也就是每 30 秒比一次 cookie 有没有变、变了就写盘。

写盘这段值得看,因为它决定了磁盘上会留下几个文件:

temp_path = json_path.with_suffix('.json.tmp')
temp_path.write_text(json.dumps(merged_state, indent=4, ensure_ascii=False), encoding='utf-8')

if json_path.exists():
	backup_path = json_path.with_suffix('.json.bak')
	json_path.replace(backup_path)

temp_path.replace(json_path)

先写临时文件,再把旧文件挪成 .json.bak,最后原子替换。写入前还会读已有文件做一次 _merge_storage_states,按 (name, domain, path) 三元组合并 cookie、按 origin 合并 storage,新值覆盖旧值。工程上稳,安全上你得记账:同一份会话凭据现在有主文件和备份文件两处明文副本。

回灌侧有两个细节。cookie 的 expires 为 0 或 -1 时会被移除该字段,代码注释解释了原因——Playwright 导出的会话 cookie 用这两个值,而 CDP 会把 0 当成已过期。origins 里的 localStorage 与 sessionStorage 则是拼成一段初始化脚本注入的,脚本外面套了一层 origin 判断:

f'  if (window.location && window.location.origin !== {json.dumps(origin_value)}) return;\n'

注释写的是避免跨站污染。这个防护是必要的:storage 项是按 origin 存的,若不加判断,注入脚本会把 A 站的 storage 写到 B 站去。

配置层面有一处冲突需要知道。storage_stateuser_data_dir 同时设置(且 user_data_dir 路径里不含 tmp)时,warn_storage_state_user_data_dir_conflict 会告警:storage_state 会强行覆盖 user_data_dir 里的 cookie 与 storage,并给出两种正确姿势——要么只用 storage_stateuser_data_dir=None,要么每个浏览器一个独立 user_data_dirstorage_state=None。前面那个 WhatsApp 登录示例(examples/apps/msg-use/login.py)恰好两个都传了,属于示例里的写法,不是推荐配置。

还有一件事影响你的数据面:BrowserProfile._copy_profile。当 user_data_dir 指向一个 Chrome 的目录时,它会把该 profile 目录整份复制到 tempfile.mkdtemp(prefix='browser-use-user-data-dir-') 建的临时目录里,连 Local State 一起拷,只跳过锁文件与日志类的临时文件。复制失败且判定为锁冲突时抛 RuntimeError,提示你关掉 Chrome 窗口或改用 --cdp-url 连已运行的浏览器。这个设计避免了污染你的真实 profile,但意味着系统临时目录里会多出一份你完整的登录态拷贝。

五、边界与代价:它明确不管什么

占位机制不管截图。 examples/features/secure.py 的注释把这条写在明面上:默认会把截图传给模型,截图里可能包含你的信息,不想传就设 use_vision=False。文字侧遮蔽做得再细,视觉通道是另一条独立管道。同一个文件还顺手关了遥测(os.environ['ANONYMIZED_TELEMETRY'] = 'false')并在 profile 里关掉默认扩展,这三件事是一组,缺一件都算漏。

遮蔽不管等价形式。 redact_sensitive_string 是精确字符串替换。页面把值改了格式再回显、或者只回显后四位,遮蔽就命中不了。这不是 bug,是这个方案的能力上限。

域名门禁不管子资源。 SecurityWatchdog 监听的是导航类事件与标签页创建,页面里的 XHR、iframe 内的请求、图片信标不在它的拦截路径上。凭据一旦被填进页面,页面把它 POST 到哪里由页面自己决定。这也是为什么域名限制必须配合”只在正确域名下才解占位”一起看——前者收窄落地页面,后者收窄凭据能出现在哪个页面。

它不替你判断目标站点允不允许自动化。 项目提供的是能力,目标站点的使用条款、验证码与反自动化机制、账号被判为异常的风险,都在你这边。BrowserProfile 里的 captcha_solver 字段默认为 True,但字段描述说清了它只是监听浏览器代理发来的验证码事件并在处理期间暂停 Agent 步骤,只在浏览器会发出对应 CDP 事件时才起作用,其它情况下无害地空转——把它理解成流程协调而非破解。同理,deterministic_rendering 的校验器直接告警说不推荐,因为它会破坏很多站点并增加被反自动化系统拦下的概率。真正需要你判断的是:这个账号在这个站点上被自动化操作,是否符合你与站点之间的约定。这层判断没有代码能替你做。

默认扩展是第三方代码。 enable_default_extensions 默认为真(可用 BROWSER_USE_DISABLE_EXTENSIONS 环境变量关掉),会下载并加载三个扩展:uBlock Origin Lite、“I still don’t care about cookies”、Force Background Tab;其中 cookie 那个还会被打一小段补丁,把 cookie_whitelist_domains(默认 ['nature.com', 'qatarairways.com'])预置进扩展存储。这些扩展跑在同一个带着你登录态的浏览器里。对内部后台自动化,secure.py 的做法更稳妥:把它关掉。

告警不是拦停。 前面提到的所有 model_validator 与构造期检查,输出都是 warning。没人会因为配置不安全而拒绝启动。你的 CI 里如果不盯日志,这些提示等于不存在。

六、上手与避坑清单

坑一:只传 sensitive_data,不配 allowed_domains 为什么会踩:占位机制看起来已经”保护”了密码,容易以为够了。实际是 Agent 到了任意站点都可能被诱导填入——尤其平铺写法对所有域名生效。怎么避:分组写法 + allowed_domains,两者同时配,并把启动日志里那条含”not locked down”的告警当成硬失败对待。

坑二:占位名取得太像普通词。 为什么会踩:_replace_sensitive_data 除了处理 <secret> 标签,还会把”参数值恰好等于占位名”的情况替换成真实值。你的键叫 nameemailtelephone(示例里就是这几个),而 Agent 在某个搜索框里正好要输入 name 这个词,输进去的就是真实值。怎么避:占位名加前缀,让它不可能是任何页面上的正常输入内容。

坑三:白名单列表跨过 100 条。 为什么会踩:转 set 是自动的,只打一行 warning,通配符失效的表现是”某些子域突然连不上”,很难第一时间归因到条数。怎么避:白名单条数控制在阈值以下,或者索性全用精确域名、不依赖通配符;把那行 optimizing domain list 的日志加进关键字告警。

坑四:以为 prohibited_domains 能给白名单打补丁。 为什么会踩:两个字段并列摆在配置里,看不出优先级。实际白名单非空时黑名单完全不参与判断。怎么避:二选一。要精确控制就只用白名单,把该排除的直接不写进去。

坑五:storage_stateuser_data_dir 一起传。 为什么会踩:两个都是”保持登录”的手段,直觉上以为叠加更稳。实际是前者强行覆盖后者,并且并行跑多个浏览器时会互相打架。怎么避:按告警里给的两种组合选一种;并行场景优先”只用 storage_stateuser_data_dir=None”。

坑六:把 storage_state.json 当普通配置文件对待。 为什么会踩:它是 JSON、能读、看着像配置,于是被 git add 或者被打进镜像。实际里面是可直接复用的登录凭据,而且默认每 30 秒刷新一次,还会额外留一份 .json.bak。怎么避:主文件与备份文件一起进 .gitignore,目录权限收紧,用完删;同时记住 _copy_profile 会在系统临时目录留一份 Chrome profile 拷贝,也要纳入清理范围。

坑七:只做了文字遮蔽就上生产。 为什么会踩:_filter_sensitive_data 的效果在日志里立刻可见,容易产生已经覆盖完全的错觉。实际截图通道默认是开的。怎么避:涉及真实凭据的任务,按 secure.py 那组配置一起上——use_vision=False、关遥测、关默认扩展、锁 allowed_domains

收尾

把这三块放在一起看,browser-use 的取向是清楚的:它不假设模型可信,所以真实值到动作执行前一刻才注入;它也不假设页面可信,所以导航前后各查一次、新标签页也查;但它假设会把配置配对——所有不安全的组合都只打告警。这条假设是你要接管的部分。

上线前的自检就四问:allowed_domains 是否锁到了具体域名而非通配到一大片;sensitive_data 是否用了域名分组而不是平铺;use_vision 在涉密任务里是否关掉;storage_state.json 及其 .json.bak 是否在版本控制之外。

想继续往下读,路径建议按数据流走:先 browser_use/tools/registry/service.py_replace_sensitive_data 看值怎么进去,再 browser_use/agent/message_manager/service.py_filter_sensitive_data 看值怎么被挡回来,最后 browser_use/browser/watchdogs/ 目录下那 14 个 watchdog 通读一遍——门禁与登录态只占其中两个,剩下十二个各管一件事,看完你才知道一个浏览器会话里到底有多少条独立管道。这个项目采用 MIT 许可证,代码可以直接读、直接改,遇到跟你场景冲突的默认值,换掉比绕开省事。

本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 结构化输出的 schema 约束与字段缺失排查browser-use 的产物落盘

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。