表单提交交给谁?收邮箱那张表一半没写去向
本文目录
- 按下提交那一刻,浏览器照着谁的话做?
- 这一屋子表单里,有几张是给人填的?
- 收邮箱那张表,action写的是什么?
- 跨域的那12个,去了哪几个域名?
- 隐藏域里装的到底是什么?
- 名单标签:你被分进哪一档,写在框边上
- 一个页面把访客的公网IP写进了隐藏域
- 最长的那个值有2589个字符
- 没写method的那些,如果那段脚本没接住会怎样?
- 原始HTML里没有、渲染之后才冒出来的表单
- 表单里那个叫id的字段,把表单自己的名字顶掉了
- 读数取决于你等多久、点没点
- 照着改,先动哪几处?
- 常见问题解答
- 表单本来就是JavaScript提交的,action写不写真的有区别吗?
- 订阅表单直接提交到邮件服务商的域名,有没有问题?
- 隐藏域里放商品ID、来源标签这些,算不算多余的字节?
- 怎么快速查一遍自己站上的表单去向?
- 为什么页面上form元素那么多,能填的却那么少?
- 用curl抓HTML来做表单审计够不够?
- 权威参考资料
摘要:接着上一篇的样本往下拆,这次数的是表单本身。55个站的191个页面上共有985张form元素,站内按签名去重后362张,其中当场有可见可填项的只有120张,33.1%。收邮箱那一格去重后182个,源码里写明了提交地址的85个——46.7%。剩下的一半,去向要么写在JavaScript里,要么根本不在这一页上。另有12个邮箱框直接把地址写成了别人的域名,767个隐藏域里躺着访客的公网IP、浏览器名字和名单标签。
上一篇数的是输入框自己说了什么:这一格要邮箱还是要电话,允不允许浏览器代填,空着能不能提交。表单字段那四件事写不写,取决于这个框是谁加上去的。
这一篇往后挪一格:用户把字打完,按下那个按钮,东西去哪儿。
这件事页面上永远不会写。没有哪个站会在订阅框底下加一行小字说“你的邮箱将被提交到manage.kmail-lists.com”。但在源码里它是有位置的——form元素上的action属性,就是干这个的。所以这一轮的问题很单纯:那个位置上,到底写了什么。
按下提交那一刻,浏览器照着谁的话做?
先把这条路径摊开。一个规规矩矩的表单提交,浏览器读三样东西:action决定往哪个地址发,method决定用GET还是POST,enctype决定怎么打包。三样都不写也能跑——按MDN对form元素的说明,action缺省是当前页地址,method缺省是GET。
但今天大部分站不走这条路。前端脚本会拦下提交事件,自己拿fetch把数据送出去。这时候action写什么都不影响真实去向,它变成一个纯粹的声明。
声明没用吗?有三个场合它照样管事:
- 脚本还没加载完、加载失败、被拦截器挡掉的时候,浏览器会退回去照HTML做;
- 读页面的程序——搜索引擎、比价插件、AI代理——只看得见HTML,看不见那段脚本的意图;
- 你自己排查问题的时候,它是唯一一个不用打开调试器就能看到的线索。
所以这一轮量的是声明,不是流量。这里得先说清楚边界:本文没有真的提交过任何一张表单,所有结论都是“如果按HTML写的去做会怎样”。这一点跟代理能不能在你站上做事那一轮的口径一致——那次判的是机器能不能构造出请求,这次问的是这张表交给谁。
这一屋子表单里,有几张是给人填的?
55个站、191个页面,取到985张form元素。同一个站里action、method、控件数、隐藏域名字、按钮文案全都一样的算同一张,去重后362张,每站中位6张。
然后按“有没有一格是给人填的”分开数:
| 这张表单长什么样 | 张数 | 占比 |
|---|---|---|
| 当场有可见可填项(真表单) | 120 | 33.1% |
| 有可填项,但当场一个都看不见 | 128 | 35.4% |
| 只有隐藏域,没有一格给人填 | 86 | 23.8% |
| 完全空的,一个控件都没有 | 28 | 7.7% |
三分之一是给人填的,剩下三分之二各有各的用途。第三档那86张最典型:action写着/cart/add的32张、/localization的17张、/cart的13张、/api/cart的7张。加购按钮是一张表单,切换国家和币种是一张表单,购物车更新是一张表单。那批/localization表单背后是地区与币种的切换,发货范围与退货范围对不上那一轮量的就是这条线的另一头。它们借用form这个壳,是因为它天生就能带一堆参数走。
这个用法本身没毛病,反而是规规矩矩的老派写法。有意思的是比例:页面上的form元素,多数不是用来收用户输入的,是站点自己跟后端说话的通道。所以“这一页有几张表单”这个数,对判断用户体验几乎没有参考价值。
再看真表单那120张有多小:可见可填项只有1个的69张,2个的32张。八成的真表单是一两个格子的小东西——页脚订阅框、搜索框、登录框。那种十几个字段的长表单在这批站上是稀有物。搜索框那一格尤其典型,它是站上被用得最多的输入框,实现上却最单薄——站内搜索页那一轮实测把它下游那一页的问题也数过一遍。
顺带记一笔:整整191个页面里,一个form元素都没有的只有7个,占3.7%;其中dreametech的四个页面全都没有。这跟首页正文空壳那一轮的结论对得上,能读到的东西越少的站,越是什么标记都不写。
收邮箱那张表,action写的是什么?
选邮箱这一格的理由跟上一篇一样:它是这批站上唯一一件人人都在做的事,51个站都有,横着比才公平。去重后182个邮箱框,先看它们挂在谁名下。
167个在某张form里,占91.8%;15个不在任何form里,占8.2%,出自10个站——aloyoga、cluse、everlane、gymshark、jackery、liquiddeath、mackweldon、misen、reebok、taylorstitch。这15个框,去向连个位置都没有。
剩下167个里,action那一栏是这么分布的:
| action写了什么 | 个数 | 占在form里的比例 |
|---|---|---|
| 压根没写(提交回当前页) | 82 | 49.1% |
| 写了本站地址 | 73 | 43.7% |
| 写了别人的域名 | 12 | 7.2% |
把不在form里的那15个也算进来,结论是:182个收邮箱的框里,源码里能看出去向的85个,46.7%。另外那一半,你在HTML里翻不到任何线索——得打开调试器,或者干脆去读那段打包过的脚本。
写了本站地址的那73个也值得看一眼。前几名是/account/login(18个)、/account/recover(15个)、/contact#contact_form(7个)。这些全是电商平台默认主题里的原件,跟上一篇那条规律接得严丝合缝:写得全的那些框,几乎都是平台替你写的。
跨域的那12个,去了哪几个域名?
12个不多,但它们是这一轮最干净的样本——因为它们把去向写在了明处。
- manage.kmail-lists.com:8个;
- a.klaviyo.com:2个;
- app.powerfulform.com:2个。
前两个是同一家邮件营销服务的两个入口,第三个是表单托管工具。涉及10个站:beistravel、fahertybrand、glossier、liquiddeath、magicspoon、marinelayer、monos、outdoorvoices、rothys、taylorstitch。
用户在这些站的页脚填一个邮箱按下订阅,浏览器发出的那个请求,收件人不是这个品牌的服务器。这件事从合规角度讲得通——服务商是数据处理方,隐私政策里通常也提到了;从用户视角讲,页面上一个字都没有。顺着这条线还有更早的一层:USENIX那份Leaky Forms研究实测过十万量级的站点,发现相当一部分站在用户按下提交之前,邮箱就已经被页面上的第三方脚本取走了。action写给谁,只是这条链路上最后一环。
更值得注意的是这12个框在上一篇里的表现:第三方托管的那一档,名字写得最漂亮(94.6%有正经名字),autocomplete几乎为零(8.1%)。营销工具在乎表单看起来专业,不在乎浏览器能不能替用户少打二十个字符。第三方脚本两天半就自己变一批这件事在这里也一样成立:这12张表单的写法什么时候变、变成什么样,不由站点定。
顺带说一句,跨域这件事在同一批页面上还有个更极端的形态。parachutehome的商品页里藏着一张action指向https://www.facebook.com/tr/的表单,整张display:none,里面83到160个type=text的输入框,名字是id、ev、dl、ts、ud[external_id]这种——那是跟踪像素把自己的请求参数做成了输入框。它不收用户输入,它只是借form这个壳装数据。这一张在上一篇里差点把统计口径带沟里去,最后被单独剔了出来。
隐藏域里装的到底是什么?
362张表单一共767个隐藏域,95.2%带着值,值长度中位数只有7个字符。按名字归类之后是这样:
| 装的是什么 | 个数 | 典型名字 |
|---|---|---|
| 表单自述(我是哪种表单、用什么方法) | 402 | form_type、utf8、_method、type |
| 商品与购物车 | 93 | product-id、id、quantity、selling_plan |
| 页面上下文 | 55 | return_to、checkout_url、page[href] |
| 地区与语言 | 47 | country_code、locale_code、options[prefix] |
| 来路追踪 | 27 | login_with_shop[analytics_trace_id]、$source |
| 名单与同意 | 19 | contact[tags]、$consent、Interest |
| 用户信息 | 11 | customer[email]、customer[id]、attributes[...] |
| 令牌与校验 | 1 | g-recaptcha-response |
名单标签:你被分进哪一档,写在框边上
contact[tags]这个字段出现11次,值很有意思:allbirds、awaytravel、drinkolipop、baseus写的是newsletter,casper写的是footer,url-homepage——它连你是从页脚还是从首页订阅的都记下来了;aloyoga写的是cc:SG,rc:SG,国家和地区各一份。taylorstitch那张表里还有个Interest字段,值是Men,另有一个$consent写着web。marinelayer的订阅表单里躺着一个Sign Up Source,值是Web Footer Sign-up。
这些都不是错。给订阅来源打标签是邮件营销的基本功。值得留意的只是一点:用户看到的是一个只要邮箱的框,实际提交出去的是邮箱加上一小串关于他的判断。这跟浏览器本地存储里那些服务器删不掉的键是同一类东西:数据的边界比界面显示的宽。
一个页面把访客的公网IP写进了隐藏域
flyingtiger的购物车表单里有一组attributes[...]:attributes[customer-account]值是logged out,attributes[browser-name]值是Google Chrome,attributes[public-ip-address]值是一个完整的公网IP地址。
这是把访客的环境信息预先写进了页面HTML,等表单提交时一并带走。技术上完全能理解——多半是给风控或者物流分区用的。但它意味着两件事:这个IP出现在了页面源码里,任何拿得到这一页的人都能读;而这一页如果被缓存住,缓存里也会留着一个具体访客的IP。HTML注释里那些构建日期和供应商名单是同一类泄漏,只是这一次泄的是用户自己的东西。
最长的那个值有2589个字符
italic的商品页上有个叫cartFormInput的隐藏域,值是一大段JSON,最长的一份2589个字符,里面装着整个购物车的商品清单。同一个站的另一个隐藏域叫analytics,值也是JSON,开头是{"products":[{"id":"gi...。liquiddeath的_keyLabel是582个字符。
这些字节每次渲染都要发一遍,而且是不可缓存的那一部分——首页字节里58%缓存寿命为零那一轮量过这笔账,隐藏域里的JSON正是其中一种。
没写method的那些,如果那段脚本没接住会怎样?
167个在form里的邮箱框,method这一栏:POST的101个,明确写GET的9个,一个字没写的57个。没写就是GET,这是标准定的缺省值。
加起来66个邮箱框,按HTML写的做的话会走GET。GET与POST的差别就在这一步:GET提交意味着表单数据被拼进地址栏的查询串——邮箱会出现在URL里,出现在浏览器历史里,出现在服务器访问日志里,还会跟着Referer头传给这一页上的第三方资源。
现实中这几乎不会发生,因为那段脚本会拦下提交。但“几乎”这个词值多少钱,取决于你的脚本有多可靠:CSP白名单漂移会把脚本拦下来,SRI哈希对不上脚本会一行都不执行,广告拦截器和企业代理也会。这些场景下,浏览器会老老实实照HTML里写的那句话去做。
所以这一栏该怎么填,其实是句大白话:你希望脚本挂掉的时候发生什么,就在这两个属性里写什么。写POST加一个真实能收数据的地址,最坏情况是页面跳转一次但数据收到了;什么都不写,最坏情况是把用户的邮箱糊在地址栏上。
原始HTML里没有、渲染之后才冒出来的表单
每个页面在读完渲染后的DOM之后,又原样取了一次它的HTML源码,两边对照。190个页面里:
- form数量一致的71个,37.4%;
- 渲染后变多的81个,42.6%;
- 渲染后反而变少的38个,20.0%;
- 原始HTML里一张form都没有、渲染之后才有的27个。
input这一层差距更大:数量一致的只有19个页面,渲染后变多的139个。
变多好理解——弹层、抽屉、评价组件都是脚本插进来的。变少那20%才需要解释一下:脚本会把服务端渲染的那一份替换掉,重建时合并了几张表,或者干脆把某些不用的删了。只看HTML看不到那8个第三方域名说的是同一件事的另一面:你以为在读页面,其实读的只是页面的初始状态。
这对做审计的人有个直接后果:拿curl抓一份HTML去数表单,会漏掉四成多的页面上的东西;但只读渲染后的DOM,又会看不见那20%被替换掉的原件。两边都得看。
表单里那个叫id的字段,把表单自己的名字顶掉了
这一轮的脚本第一版在44个页面上直接崩了,报错是“这个值上没有replace方法”。查下去发现的东西,比那个报错本身有意思得多。
HTML标准的表单控件章节给form元素开了个后门:表单里那些控件的name和id,会被挂成表单对象自己的属性,而且优先级高过表单原本的属性。商品页上的加购表单通常有个<input name="id">装着变体ID,于是读form.id读回来的不是字符串,是那个输入框元素本身。
全样本里275张表单实例中招,出自33个站。被顶掉的属性绝大多数是id(274次),还有1次是submit——那张表单里有个name叫submit的控件,把表单的提交方法顶掉了,脚本要是调用form.submit()就会当场报错。
这个坑对写审计脚本的人是个直接教训:读表单属性一律用getAttribute,别用点号。对做站的人则是另一条:给控件起名字的时候,避开id、name、action、method、submit这几个词。字段名的写法能引出多少麻烦,这算是最物理的一种。
读数取决于你等多久、点没点
上面所有百分比,量的都是同一个状态:页面载入后等9秒左右、不做任何操作。这个状态到底有多不稳,另挑了29个首页做对照,同一个地址读三次——等5秒、再等20秒、然后替用户点一下页面上的订阅或搜索入口。
| 对照 | 读数变了的页面 |
|---|---|
| 只多等20秒,form数变了 | 17.2% |
| 只多等20秒,可填控件数变了 | 20.7% |
| 点一下之后,form数变了 | 26.1% |
| 点一下之后,可填控件数变了 | 56.5% |
| 点了完全没变化 | 39.1% |
总量上看更直白:这29个首页上的可见可填控件,等5秒时37个,等25秒时42个,点一下之后70个。接近一半的输入框,是在有人动手之后才存在的。
几个具体的:gymshark首页原本一个form都没有,点开那个写着Email Sign Up的按钮之后冒出1张表单7个控件;jackery点完订阅按钮,form从2张变成5张;aloyoga点开账号入口,控件从2个变成6个;brooklinen什么都不点,光是多等20秒,form就从1张变成3张。
反过来,39.1%的页面点了也没有任何变化——那些框本来就在DOM里,点击只是掀开了一层样式。这跟隐藏内容那份裁决表讲的是同一件事:藏起来有很多种藏法,机器算不算数要看藏法。
照着改,先动哪几处?
四条,按性价比排。
第一条,给每个收邮箱、收询盘的表单补上一个真实可用的action和method。不是为了让浏览器去提交,是为了脚本挂掉那一刻页面还有个说法。做法很简单:后端提供一个能收表单编码数据的端点,写成method="post" action="/xxx",前端脚本照旧拦截。这一步只花几分钟,收益是把一半查不到去向的表单变成查得到。
第二条,把那些不在任何form里的输入框放回form里。182个邮箱框里有15个是孤儿,10个站中招。放回去顺手就能解决一串问题:回车键能提交了、密码管理器认得出来了、代理程序能看出这几个格子是一组了。
第三条,翻一遍自己站上的隐藏域,看看有没有本来不该出现在HTML里的东西。判据是问三个问题:这个值是不是关于某一个具体访客的?它会不会被缓存住?拿到这一页的人读到它要不要紧?公网IP、账号状态、完整的购物车JSON,都得按这三条过一遍。合规架构那篇讲的同意与数据边界,落到实现层就是这一堆<input type="hidden">。
第四条,做审计时两边都读。只读HTML漏掉四成多,只读渲染后的DOM看不见被替换掉的两成。保哥自己那套体检脚本现在是固定跑两遍,两份对不上的页面单独列出来人工看,成本不高,能挡掉大部分误判。
最后说一句选型上的判断。form这个元素在很多前端框架里已经被当成可有可无的壳,数据用状态管住,提交用fetch发出去,看起来更干净。但这一轮的数据摆在这儿:那个壳是页面上唯一一处能写下“这些格子是一组、它们要去哪儿”的地方。省掉它,省的是十几个字符,丢的是这一页对外唯一的一份说明书。换个平台只是换一批老代码那篇讲过前端习惯的惯性有多大,这一条同样是惯性的产物。代理时代那笔账已经算过一次,这里只是再补一个更基础的注脚。
常见问题解答
表单本来就是JavaScript提交的,action写不写真的有区别吗?
有。三种场合它会真的生效:脚本没加载完就有人按了回车、脚本被CSP或者拦截器挡掉、用户的网络中途断了又恢复。另外读页面的程序只看HTML,你写了它就知道这张表往哪儿提交,不写它只能猜。成本是十几个字符,不值得省。
订阅表单直接提交到邮件服务商的域名,有没有问题?
技术上没问题,合规上要看你有没有在隐私政策里把这家服务商列为数据处理方。真正要注意的是另一件事:这类表单的HTML由服务商生成,它的字段声明写得好不好、什么时候变,都不由你定。本轮那12个跨域邮箱框的autocomplete覆盖率只有8.1%,就是这么来的。
隐藏域里放商品ID、来源标签这些,算不算多余的字节?
算,但通常值。要警惕的是两类:一是关于具体访客的值,比如IP、账号状态、完整购物车JSON,它们会跟着页面一起被缓存;二是几千字符的大块JSON,那部分字节每次渲染都要重发。判据是问一句:这个值能不能在提交那一刻由脚本现算出来。
怎么快速查一遍自己站上的表单去向?
控制台里跑一句[...document.querySelectorAll('form')].map(f=>[f.getAttribute('action'),f.getAttribute('method'),f.elements.length])就能看个大概。注意一定要用getAttribute,别写f.action——表单里只要有个name叫action的控件,点号读回来的就是那个控件而不是地址。
为什么页面上form元素那么多,能填的却那么少?
因为form这个元素在电商模板里被大量用作参数容器:加购、切换国家币种、更新购物车,全都是一张表单。本轮362张去重后的表单里,只有隐藏域的86张,完全空的28张。所以“这一页有几张表单”对判断用户体验没什么参考价值,得按有没有可见可填项再分一次。
用curl抓HTML来做表单审计够不够?
不够,但也不能不抓。本轮190个页面里,渲染后form数量变多的占42.6%,只抓HTML会漏掉这一批;而渲染后变少的占20.0%,只读DOM又会看不见原件。两份都取,对不上的单独看,这是目前最省事的做法。
权威参考资料
本文标题:《表单提交交给谁?收邮箱那张表一半没写去向》
本文链接:https://zhangwenbao.com/form-submission-destination-audit.html
版权声明:本文原创,转载与引用请注明作者与原文链接。许可协议: CC BY 4.0
← 上一篇
一张礼品卡的结构化数据119KB,同一段配送政策抄了60遍下一篇 →
没有了