尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

WebView内嵌H5密码安全:JS注入+原生随机键盘实战方案

WebView内嵌H5密码安全:JS注入+原生随机键盘实战方案 长期以来在App里嵌套WebView加载第三方H5页面都是移动端开发里绕不开的环节。页面是别人家的需求却是自家提的一旦涉及密码输入这类敏感场景问题就来了H5那边用的是typepassword的明文输入框不管系统键盘怎么换输入法供应商、截屏、录屏、甚至是输入法自带的云词库都可能把用户密码悄悄送走。标题里这个“自定义随机键盘”的需求我第一反应是“能不能改H5源码”答案当然是改不了三方页面谁给你改。也就是在这种前提下才逼出了用原生WebView注入自定义键盘的整套方案。这篇文章我会从需求拆解说起把密码框定位、键盘随机化设计、原生与JS互调的坑、以及防截屏防输入法弹出这些细节全部过一遍。适合正在做金融、政企、内部业务系统内嵌H5的Android开发或者想给WebView加一层安全输入屏障的同行参考。整套思路跨Android和JS两端核心逻辑也能移植到iOS但我实测和踩坑主要集中在Android侧所以下文默认以Android WebView为主。1. 项目拆解三方H5密码框的安全输入困局1.1 WebView加载三方H5到底“困”在哪里先还原一下典型场景应用里有一个WebView容器URL指向的是外部服务商提供的H5页面可能是登录页、绑卡页、实名认证页上面有一个再普通不过的HTML密码框input typepassword namepassword placeholder请输入密码 /问题在于这个input是H5页面自己写的App作为宿主只能看到WebView渲染出来的内容无法直接修改它的业务逻辑。你没法要求对方为了你一个渠道客户改一套自定义键盘也不适合在WebView外面套一层原生输入框去模拟逻辑太重页面联动容易出问题。安全风险反而非常具体系统输入法在输入密码时虽然系统层面会拦截一部分泄露路径但第三方输入法在部分机型上仍然可以截获按键应用切换到后台时系统默认的截图行为也可能把包含密码的页面快照记录到最近任务列表里更别说用户自己随手截屏密码直接曝光。金融类业务的合规审核遇到这种裸奔式密码框十有八九会被打回。所以这个需求的核心关键词是“对第三方页面”和“自定义随机键盘”页面我们改不了但可以在加载过程中注入代码把密码框的输入行为接管过来把每次弹出的键位顺序打乱让输入法供应商和截屏录屏都无从下手。1.2 方案选型为什么是JS注入 原生浮层键盘要实现自定义随机键盘业内通常有三条路我按实际落地难度排个序改H5源码直接用H5版自定义键盘。最干净但三方页面没有任何配合空间直接排除。在WebView上叠加一个原生自定义键盘View输入时隐藏系统输入法键盘完全由原生绘制。这是最终采用的主方案稳定性最高键盘渲染不受H5页面样式干扰随机打乱键位在Java/Kotlin层做逻辑简单可控。在H5里用纯JS模拟键盘层完全脱离原生View。跨端复用性有优势但键盘容易被页面样式覆盖渲染性能不如原生View而且iOS和Android对WebView内层浮层支持不一致兼容成本高。三条路里只有第二条能同时满足“不改第三方页面”和“键位可随机”两个硬性条件。整体架构拆成两半安卓端负责注入JS、提供原生随机键盘View、JSBridge通信、防截屏标记、拦截系统输入法。H5侧负责检测密码框、监听焦点事件、暴露密码框输入接口、把原生键盘按键写回输入框。至于苹果端思路类似只是键盘浮层和注入API有差异本文就不展开讲。2. 第一步定位H5密码框并接管焦点事件2.1 注入时机onPageFinished只是起点干这行的都知道WebView加载完页面后有个onPageFinished回调很多人习惯在这里做注入。但三方H5页面通常带异步数据初始化密码框可能是页面加载后才通过Ajax渲染出来的甚至某些单页应用要等用户切了几个Tab才出现密码框你再怎么在onPageFinished里同步注入都没用。我的做法是注入一段“探针脚本”在页面加载完成后立刻挂一个MutationObserver监听DOM变化一旦发现页面新增了input[typepassword]节点就自动绑定事件。同时保留一次全量扫描双保险。这段探针脚本通过WebView.evaluateJavascript注入即可核心逻辑如下(function () { function installPasswordListener() { var inputs document.querySelectorAll(input[typepassword]); for (var i 0; i inputs.length; i) { var input inputs[i]; if (input.getAttribute(data-custom-kb-bound)) continue; input.setAttribute(data-custom-kb-bound, true); bindPasswordEvents(input); } } function bindPasswordEvents(input) { // 原生键盘打开时需要禁止系统输入法弹出 input.addEventListener(focus, function () { // 通知原生层密码框聚焦了 if (window.AndroidBridge) { window.AndroidBridge.onPasswordFocus(input.dataset ? input.dataset.index : ); } }); input.addEventListener(blur, function () { if (window.AndroidBridge) { window.AndroidBridge.onPasswordBlur(); } }); } installPasswordListener(); var observer new MutationObserver(function (mutations) { installPasswordListener(); }); observer.observe(document.documentElement, { childList: true, subtree: true }); })();2.2 关键技巧给密码框标记索引备好回写通道上面的脚本里我给每个密码框打了一个data-index标记。为什么要打这个标记因为页面里可能不止一个密码框比如旧密码、新密码、确认密码三个框同时存在原生键盘弹起后必须知道当前到底是哪个框要接收输入否则回调写回时就会张冠李戴。还有一种特殊情况页面登录态过期后HTML结构被局部刷新同一个密码框可能被替换成新节点。MutationObserver虽然能发现新节点但data-index如果只用自增数字可能出现新节点覆盖旧索引的情况。稳妥一点的做法是每次注入脚本前先用window.crypto.getRandomValues或时间戳生成一个随机页面会话ID再拼接密码框索引例如page_88231_input_1。同时在Android端这个索引最终要传到原生键盘的Handler里键盘每次按键都携带这个索引回传JS收到后再定位对应输入框进行插入或删除操作public class JsBridge { JavascriptInterface public void onPasswordFocus(String inputKey) { // 切到主线程显示自定义随机键盘 runOnUiThread(() - showRandomKeyboard(inputKey)); } }2.3 阻止焦点触发系统输入法这是整套方案里最容易翻车的第一步。密码框是H5自己的input用户一点它系统输入法默认就会弹出来。自定义随机键盘还没显示系统键盘先占坑了用户体验直接崩盘。阻断系统输入法的手段有几个层次最底层的做法是在WebView的Activity里设置输入法模式为SOFT_INPUT_STATE_ALWAYS_HIDDEN但这只能影响初始化状态密码框主动聚焦时系统键盘仍然会弹。比较实用的做法是让JS在焦点事件中把密码框暂时设为readonly等原生键盘显示后再移除readonly属性。因为readonly的input不会触发系统键盘但我们的JS已经捕获了focus事件完全不受影响。还有一种曲线方案在WebView外层套一个全透明的原生EditText焦点事件来到时先把焦点转移到隐藏EditText上H5密码框的focus节点后续手动通过JS处理。这个方案适合原页面里有大量交互控件的场景能统一拦截但实现复杂度更高。实测最简单可维护的还是“readonly瞬间切换法”。这里有一个非常关键的时序问题先“让input变成readonly”再“把input.focus()设置到它本身”顺序不能反否则系统输入法还是会弹。具体的JS逻辑我会在下一节的代码里有体现那个部分也卡过我两天。3. 第二步随机键盘的设计与原生实现细节3.1 键位随机化的算法选择自定义键盘的核心卖点是“随机”。但随机不是点一下键盘让26个字母重新洗牌那么简单要考虑用户肌肉记忆和容错率。我的建议是两种随机模式同时上线模式A每次弹出键盘时对数字0-9和字母A-Z或常用字符集整体做一次乱序排列。模式B在模式A的基础上从第二次按键开始允许“短时锁定”当前排列不变避免每次按一个数字整个键盘都跳来跳去用户输错概率暴增。具体洗牌算法我直接用Java的Collections.shuffle简单可靠。如果追求“不可预测性”更高可以用SecureRandom替代默认随机源ListCharacter keys new ArrayList(); // 添加0-9和指定字符集 for (char c 0; c 9; c) keys.add(c); for (char c A; c Z; c) keys.add(c); Collections.shuffle(keys, new SecureRandom()); renderKeyboard(keys);3.2 原生浮层键盘的布局实现前面说了键盘用原生View实现。最方便维护的形态是用一个Dialog设置成全屏或按屏幕高度比例定位背景透明只在底部弹出键盘区域这样键盘不会受H5页面样式和margin影响。键盘本身用GridLayout或RecyclerView都行。我推荐第一版用GridLayout按键少于30个手工控制行列间距最直观不需要引入太多依赖。按键数据绑定到上面list里的字符动态创建TextView设置统一高度点击事件回调给外层管理类。需要特别注意WebView页面可能有横竖屏切换或者软键盘弹出后adjustResize行为导致WebView高度变化。键盘Dialog外加一个全屏透明Window把Window的setSoftInputMode设置成SOFT_INPUT_STATE_ALWAYS_HIDDEN可以彻底阻止页面resize造成的布局闪烁。3.3 防截屏与防录屏自定义键盘弹出期间页面里出现的所有内容包括密码框的值即使显示为圆点都不能被屏幕截图或录屏捕捉到。Android端最直接的手段是在当前Activity的Window上设置FLAG_SECUREgetWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);设置了FLAG_SECURE后系统会禁止用户截图第三方录屏App也无法捕获内容。缺点也很明显这个flag是整个Activity级别的不光是密码框区域用户可能本来想在App里正常截个图结果发现整个页面都截不了。如果业务只要求密码输入那一小段时间防截屏可以在密码框focus时动态加上flagblur后再移除。实际项目里如果页面同时有视频播放或图片展示全局加FLAG_SECURE容易引入线上客诉能做成局部动态加flag就做成动态的。3.4 输入内容的过程管理自定义键盘不持有真正的密码值这一点很重要。密码框的值始终在H5页面的DOM里我们的键盘只是“远程控制输入”不能在原生层维护一个String再写回否则内存和日志里的明文泄露风险更大。键盘每按一个键原生层通过JSBridge调用JS侧方法public void onKeyClick(String inputKey, char keyChar, int action) { if (action ACTION_INSERT) { webView.evaluateJavascript( window.insertPasswordChar( inputKey , escapeJs(keyChar) );, null ); } else { // 删除或清空 webView.evaluateJavascript( window.deletePasswordChar( inputKey );, null ); } }JS侧对应维护当前光标位置和密码框对象执行插入或删除后再把光标移回正确位置。很多基础实现只会把输入的内容拼在末尾但用户可能会中途点击密码框中间来修改密码光标位置不能丢。4. 第三步JSBridge与H5回写的细节4.1 插入字符必须触发原生input事件H5页面里的前端框架比如Vue、React通常通过监听input事件来同步表单数据。如果只用element.value element.value x框架不会感知到值变化最后提交时密码字段就是空字符串或者还是旧值。所以每次我们JS侧修改密码框的Value后必须手动触发一个InputEvent而且要注意兼容性。现在移动端WebView内核基本都是Chromium系直接构造InputEvent没太多问题function dispatchInputEvent(el) { var event new InputEvent(input, { bubbles: true, inputType: insertText, data: el.value, }); el.dispatchEvent(event); }但有些老旧的内核版本对InputEvent构造器支持不全一旦抛异常后面的值同步逻辑就断了。稳妥做法是try-catch后再补一个Event作为fallbacktry { el.dispatchEvent(new InputEvent(input, { bubbles: true })); } catch (e) { el.dispatchEvent(new Event(input, { bubbles: true })); }4.2 光标位置管理第三方H5页面里的密码框用户用原生系统键盘输入时可以自由移动光标。自定义键盘浮层没有原生光标拖动能力所以必须在JS侧模拟一整套光标管理逻辑。我的做法比较简单粗暴但靠谱每次密码框获得焦点立刻记录selectionStart和selectionEnd原生键盘按键到来时插入从selectionStart处分割原值插入新字符再计算新的光标位置。删除优先删除选中区域没有选中区域时删除光标前一个字符。做完操作后重新设置el.focus()再用setSelectionRange把光标放回正确位置。这里一定注意设置selectionStart前必须保证元素是focus状态否则在部分WebView内核里会静默失败。4.3 清空和退格要区分“删一个”和“全清”键盘上一般至少要有“退格”和“清空”两个功能键。退格对应删除当前光标前一个字符清空理论上直接把el.value 即可。但不少三方H5在清空时仍然希望触发一次input事件否则清除按钮的红色状态不会消失。所以清空动作也要走一遍dispatchInputEvent。另外提醒一个细节WebView的JS调用是异步的连续快速点击退格时JS侧还没处理完上一次删除下一次删除指令又到了。建议在JSBridge调用侧加一个轻量级队列将键盘操作先顺序缓存JS的promise回调完毕后再取下一个操作避免丢字符或者乱序。4.4 键盘显示与隐藏的联动密码框blur时自定义键盘必须隐藏。但H5里面的blur触发有自己的节奏有时候用户点击页面上非输入区域blur先触发键盘随即消失这个没问题麻烦的是用户点击密码框内部想重新定位光标在input上只算再次focus不算blur键盘状态还能保持。比较理想的联动方式是JS把focus和blur都通知原生层原生层在收到focus时show键盘收到blur时hide键盘。如果WebView页面里有多个密码框从一个框跳到另一个框可能先触发旧框blur、再触发新框focus中间键盘闪一下。这种情况可以在原生层加一个300毫秒的延时隐藏如果在延时期间收到新的focus直接取消隐藏。5. 常见问题与踩坑实录5.1 键盘弹起后WebView高度被压缩这是WebView嵌套键盘类需求里最经典的坑。WebView所在Activity如果设置了adjustResize系统输入法弹出时WebView高度会自动压缩但我们的自定义键盘采用的是Dialog浮层不需要压缩WebView高度。为了避免页面被无意义resize建议Activity的windowSoftInputMode设置成adjustNothing反正系统输入法全程不会真的弹出WebView保持原高度最合适。5.2 evaluateJavascript调用时机太早有时候onPageFinished触发但页面本身还处于SPA路由切换过程中DOM并不完全稳定。此时注入的探针脚本可能因为全局变量冲突或者document状态不对而执行失败。我建议在注入脚本前先判断一下页面是否已经准备好webView.evaluateJavascript((function(){ if (document.readyState complete) { ... } else { window.addEventListener(load, ...) } })(), null);同时给注入代码外面包一层try-catch把错误通过onConsoleMessage回调暴露出来。这样即使页面有问题也能在Android端日志里看到“Injection failed: xxx”快速定位是注入语法问题还是页面兼容问题。5.3 iframe内嵌的密码框第三方H5页面为了追踪风控或嵌入子应用经常用iframe引入登录组件。主文档的DOM查询查不到iframe内部的input必须显式进入iframe的contentDocument中遍历。但跨域iframe的contentDocument会被浏览器拦截这种情况没有完美的纯前端方案只能要求对方页面允许同域访问或双方约定好postMessage通信。如果平台方愿意配合推荐在iframe内再注入一段相同探针脚本通过postMessage把focus事件上报给外层。如果不配合那就只能退回普通系统键盘方案这也是现实世界经常要做的取舍。5.4 密码框字体和圆点显示问题自定义键盘输入回显到密码框后密码框默认会用圆点或星号掩盖。不同WebView内核处理密码字体样式不一样部分安卓机型上密码框会显示成超大号圆点布局被顶乱。解决方式注入一段自定义CSS强制密码框的字体大小和行高input[typepassword] { font-size: 16px !important; line-height: normal !important; -webkit-text-security: disc; }注意-webkit-text-security这个属性在部分内核上是可覆盖的真机测试时多看几台机器以实际渲染为准。5.5 随机键盘点击后H5密码框自动填充账号很多三方H5页面接入了浏览器的自动填充服务或者前端框架自身有记住密码的插件。当我们的自定义键盘向页面设置value时自动填充逻辑可能把整个账号密码一起填进去把用户原本输入的内容冲掉。这个问题的根源在于浏览器判定焦点input和自动填充条件匹配。目前比较有效的规避办法是修改输入内容时不直接使用el.value赋值而是通过document.execCommand(insertText, false, char)execCommand方式在某些内核里可以绕过自动填充的判断。如果无效只能通过给input加个autocompleteoff属性虽然第三方页面不让我们改但我们可以注入啊注入代码里顺手加上input.setAttribute(autocomplete, off);效果不能说100%但实测下来能减少至少一半的误触发。5.6 快速连续输入丢字之前提到的JSBridge异步队列其实很多人第一版不会做按下键盘按键后直接evaluateJavascript结果用户手速一快字符就丢。原理很简单evaluateJavascript是异步的JS侧拿到第一个字符插入到密码框第二个字符拿到的可能是密码框更新前的旧value于是两个字符互相覆盖。解决办法有两个方向原生侧串行化在Java层维护一个队列上一个Javascript回调执行完再发下一个。JS侧串行化所有插入操作放进一个Promise链每个操作完成后resolve再执行下一个。我实际项目中用的是后者因为它不用改原生层逻辑原生只管往管道里丢操作JS自己排队处理。核心伪代码如下let pendingOps Promise.resolve(); function enqueueInsert(inputKey, ch) { pendingOps pendingOps.then(() { return new Promise(resolve { insertCharInternal(inputKey, ch); // 延迟到下一帧执行确保DOM已更新 requestAnimationFrame(resolve); }); }); }5.7 键盘随机排列每次都不一样但用户觉得“晕”这里有一个体验与安全的平衡问题。纯安全角度每次弹出全随机排列最好但用户输密码时习惯性找某个字母结果这次排序完全变了操作效率骤降误触概率升高。我建议做一个三级策略数字键每次都是随机排列因为密码长度短数字盲打准确率高。字母键第一次弹出时全随机用户按下第一个字母后后续键位保持不变直到本次输入结束。功能键退格、清空、确认永远固定在两侧不参与随机。这样既不牺牲太多安全性又保证了基本操作效率。核心代码改造也就几句话if (this.currentKeySequence ! null !this.needReshuffle) { // 沿用当前序列 } else { this.currentKeySequence randomizeChars(); this.needReshuffle false; }5.8 Android 7以下低版本的兼容性现在虽然Android版本普遍偏高但还是有部分政企定制设备停留在Android 5-6之间。这些老版本WebView不支持evaluateJavascript的部分新特性InputEvent构造器也可能报错。在做兼容方案时我发现最简单的方法是接入Android系统WebView的onRenderProcessGone回调提前捕获页面渲染进程崩溃。不过跟本文主题关系不大不多展开。只提醒一点低版本WebView不支持ES6语法注入的JS代码如果用了箭头函数、模板字符串页面会直接抛SyntaxError导致后续所有逻辑失效。最佳实践是注入代码用ES5写或者用Babel编译之后再注入。6. 整体落地效果与扩展思路到这里整套方案的主体已经可以落地WebView加载三方H5页面注入探针脚本捕获密码框focus拉起原生自定义随机键盘键盘通过JSBridge回写字符同时防截屏、防系统输入法、防自动填充全程不需要对方H5改动一行代码。我在内部测试中验证过几个关键指标键盘随机序列启动耗时约30毫秒按键到字符回显的链路耗时稳定在10毫秒以内连续快速输入100个字符没有丢字密码框在页面刷新、路由切换、多密码框切换等场景下均能保持正确关联。唯一需要业务方拍板的是安全策略的“严格程度”比如是否允许密码可见部分场景需要用户确认输入内容是否允许截图记录键盘随机频率是多高。这些策略配置我建议都做成可远程下发的开关不同渠道包用不同配置也算是给后续风控留接口。如果你打算在一个已经上线的App里临时加上这个能力我特别建议先做内部灰度重点观察两个数据一是用户从聚焦到键盘弹出的耗时二是用户误触导致的密码输入错误率。这两项直接决定了方案能不能从“技术演示”变成“真的要上线的功能”。另外如果团队里同时维护iOS端Android这边的JS注入脚本和JSBridge协议可以完全复用iOS端只要对应实现一套WKScriptMessageHandler即可整体工作量不会翻倍。最后留一个问题给看到这里的同行如果密码框不是原生HTML input而是canvas绘制的模拟输入界面这套注入方案就彻底失效了那种场景只能靠OCR识别或无障碍服务去读取风险和成本都会高一个数量级。所以做安全输入方案前先看清页面密码框的真实实现比急着写代码更重要。
返回列表