记录一下谷歌浏览器静默开启“本地网络访问权限(LNA)”的坑

事件起因

我们的一个js插件需要连接本地(localhost、127.0.0.1)的websocket,2026-03月份的一天客户反馈这个功能无法使用了,websocket无法连接上,通过浏览器控制台查看原因,浏览器上只是浅浅的报了个黄色的警告:WebSocket connection to 'ws://127.0.0.1:10021/'failed: WebSocket is closed before the connection is established,如图:

一开始我还以为是我的代码问题,随后我用火狐浏览器、edge浏览器去访问了用户的环境,发现一点问题都没有,接着我再去查看用户的谷歌浏览器版本,发现他升级到了142.x,而我电脑的浏览器版本才138.x,因此我断定应该是浏览器版本导致的,但我又找不到问题,按理说越是高版本越不会有问题,随后我让AI帮我分析这个问题,AI给出的结论是:谷歌浏览器在 Chrome 138 中推出本地网络访问权限(LNA)的初始版,并进行了灰度更新,有部分用户的浏览器默认启用了这个功能。开启了这个功能后使用fetch、ajax、websocket等连接本地网络(localhost、127.0.0.1)会被浏览器阻断

什么是本地网络访问权限(LNA)?

本地网络访问权限 (github.com/WICG/local-...%25E4%25BC%259A%25E9%2599%2590%25E5%2588%25B6%25E7%25BD%2591%25E7%25AB%2599%25E5%2590%2591%25E7%2594%25A8%25E6%2588%25B7%25E6%259C%25AC%25E5%259C%25B0%25E7%25BD%2591%25E7%25BB%259C%25E4%25B8%25AD%25E7%259A%2584%25E6%259C%258D%25E5%258A%25A1%25E5%2599%25A8%25EF%25BC%2588%25E5%258C%2585%25E6%258B%25AC%25E5%259C%25A8%25E7%2594%25A8%25E6%2588%25B7%25E6%259C%25BA%25E5%2599%25A8%25E4%25B8%258A%25E6%259C%25AC%25E5%259C%25B0%25E8%25BF%2590%25E8%25A1%258C%25E7%259A%2584%25E6%259C%258D%25E5%258A%25A1%25E5%2599%25A8%25EF%25BC%2589%25E5%258F%2591%25E9%2580%2581%25E8%25AF%25B7%25E6%25B1%2582%25E7%259A%2584%25E8%2583%25BD%25E5%258A%259B%25EF%25BC%258C%25E8%25A6%2581%25E6%25B1%2582%2560%25E7%2594%25A8%25E6%2588%25B7%25E5%2585%2588%25E5%2590%2591%25E7%25BD%2591%25E7%25AB%2599%25E6%258E%2588%25E4%25BA%2588 "https://github.com/WICG/local-network-access)%E4%BC%9A%E9%99%90%E5%88%B6%E7%BD%91%E7%AB%99%E5%90%91%E7%94%A8%E6%88%B7%E6%9C%AC%E5%9C%B0%E7%BD%91%E7%BB%9C%E4%B8%AD%E7%9A%84%E6%9C%8D%E5%8A%A1%E5%99%A8%EF%BC%88%E5%8C%85%E6%8B%AC%E5%9C%A8%E7%94%A8%E6%88%B7%E6%9C%BA%E5%99%A8%E4%B8%8A%E6%9C%AC%E5%9C%B0%E8%BF%90%E8%A1%8C%E7%9A%84%E6%9C%8D%E5%8A%A1%E5%99%A8%EF%BC%89%E5%8F%91%E9%80%81%E8%AF%B7%E6%B1%82%E7%9A%84%E8%83%BD%E5%8A%9B%EF%BC%8C%E8%A6%81%E6%B1%82%60%E7%94%A8%E6%88%B7%E5%85%88%E5%90%91%E7%BD%91%E7%AB%99%E6%8E%88%E4%BA%88") 权限`,然后才能发出此类请求。请求此权限的能力仅限于安全上下文。

Chrome 的本地网络访问权限提示示例:

许多其他平台(例如 Android (developer.android.com/privacy-and...%25E3%2580%2581iOS "https://developer.android.com/privacy-and-security/local-network-permission?hl=zh-cn)%E3%80%81iOS") (support.apple.com/en-us/10222...) 和 MacOS (support.apple.com/guide/mac-h... mac) )都具有本地网络访问权限。例如,您可能在设置新的 Google TV 和 Chromecast 设备时, 向 Google Home 应用授予了此权限,以允许其访问本地网络。

Chrome为什么这么做? 据谷歌的说法是此举旨在保护用户免遭针对专用网络上的路由器和其他设备的跨站请求伪造 (CSRF) 攻击,并降低网站利用这些请求对用户本地网络进行指纹识别的能力。

从Chrome 138 开始,您可以前往chrome://flags/#local-network-access-check并将相应标志设置为"已启用(阻止)",选择启用这些新限制。

哪些类型的请求会受到影响?

在本地网络访问权限的第一个里程碑中,谷歌认为**"本地网络请求"**是指从公共网络到 本地网 络环回目的地 的任何请求。

  1. 本地网络是指解析为预留供本地使用的地址的任何目的地。例如

    • IPv4系列 RFC1918 (tools.ietf.org/html/rfc191...) 第 3 部分中指定的专用 IPv4 地址(例如,192.168.0.0/16) RFC3927 (tools.ietf.org/html/rfc392...) 中定义的 IPv4"链路本地"前缀 (169.254.0.0/16)
    • IPv6系列 RFC4193 (tools.ietf.org/html/rfc419...) 第 3 节中定义的 IPv6"唯一本地地址"前缀 (fc00::/7) RFC4291 (htt ps://tools.ietf .org/html/rfc4291) 第 2.5.6 节中定义的 IPv6"链路本地"前缀 (fe80::/10) 或者映射的 IPv4 地址本身是本地的 IPv4 映射 IPv6 地址(::ffff:0:0/96)。
  2. 环回是指解析为本地机器的任何目的地(即 "环回"接口),如:

    • IPv4 环回前缀 (127.0.0.0/8),或 localhost
    • IPv6 环回 (::1/128)

    如需查看 IP 地址与地址空间的完整映射,请参阅本地网络访问规范中的表格 (htt ps://wicg.github.io/local-network-access/#non-public-ip-address-blocks)。

  3. 公共网络是指任何其他目的地

**注意:**未来,谷歌计划扩展这些保护措施,以涵盖所有发送到本地网络上目的地的跨源请求。这包括从本地服务器(例如router.local)到本地网络上其他服务器的请求,或从本地服务器到本地主机的请求。

哪些请求不会受影响(内容摘自谷歌文档)

由于本地网络访问权限仅限于安全上下文,并且将本地网络设备迁移到 HTTPS 可能很困难,因此,如果 Chrome 在解析目的地之前知道请求将发送到本地网络,则受权限限制的本地网络请求现在将免于混合内容检查。如果满足以下条件,Chrome 便知道请求将发送到本地网络:

  • 请求主机名是专用 IP 字面值(例如192.168.0.1)。
  • 请求主机名是.local网域。
  • fetch()调用带有targetAddressSpace:"local".选项的配置

Chrome版本的变化

谷歌已在 Chrome 138 中推出本地网络访问权限的初始版本,供用户选择加入并进行测试。用户可以将 chrome://flags#local-network-access-check设置为"已启用(阻塞)",以启用新的权限提示。

使用 JavaScript fetch() API、子资源加载和子框架导航发起的请求可以触发本地网络访问权限提示

谷歌提供了一个演示网站,网址为[lna-testing.notyetsecure.com/(htt](https://link.juejin.cn?target=https%3A%2F%2Flna-testing.notyetsecure.com%2F(htt "https://lna-testing.notyetsecure.com/(htt") ps://lna-testing.notyetsecure.com/),用于触发不同形式的本地网络请求。

会受本地网络访问权限限制的请求

截止目前(20260527)在Chrome 142+、edge 142+中使用websocket、ajax访问127.0.0.1、localhost 会受到限限制。 后续也许使用sport 和 WebRTC 连接本地网络也会受到现在。

解决办法

使用fetch请求触发本地网络访问权限提示,让用户主动授权。 代码实现:

js 复制代码
function ensureLoopbackPermission (options) {
  var returnTruePermission = function () {
    Promise.resolve({
      state: 'granted',
      msg: '',
      permissionFlag: true
    });
  };
  options = options || {};
  if (!navigator.permissions || !navigator.permissions.query) {
    // 旧版浏览器,直接连接
    return returnTruePermission();
  }
  // 辅助函数:获取浏览器信息
  var getBrowserInfo = function () {
    const ua = navigator.userAgent;
    let isChromium = false;
    let majorVersion = 0;

    // 检测是否为 Chromium 系浏览器(Chrome, Edge, Opera, Brave 等)
    const chromiumMatch = ua.match(/(Chrome|Edg|OPR|Brave)\/(\d+)/);
    if (chromiumMatch) {
      isChromium = true;
      majorVersion = parseInt(chromiumMatch[2], 10);
    } else {
      // 使用 navigator.userAgentData 现代 API 检测(如果可用)
      if (navigator.userAgentData?.brands) {
        const chromeBrand = navigator.userAgentData.brands.find(b => b.brand.includes('Chrome'));
        if (chromeBrand) {
          isChromium = true;
          majorVersion = parseInt(chromeBrand.version, 10);
        }
      }
    }

    return { isChromium, majorVersion };
  }

  const browserInfo = getBrowserInfo();

  // 如果不是 Chromium 浏览器(Chrome/Edge/Opera等),或版本低于 142,则无需权限处理
  if (!browserInfo.isChromium || browserInfo.majorVersion < 142) {
    return returnTruePermission();
  }
  // 检测 Chrome 版本(简化示例,实际可用更精确的方法)
  const chromeVersion = parseInt(browserInfo.majorVersion || '100');

  let permissionName;
  if (chromeVersion >= 145) {
    permissionName = 'loopback-network'; // 新权限名
  } else if (chromeVersion >= 142) {
    permissionName = 'local-network-access'; // 旧权限名
  } else {
    return returnTruePermission(); // 低于 142 的版本无 LNA 限制
  }

  return navigator.permissions.query({ name: permissionName })
    .then(function (result) {
      var state = result.state;
      console.log('ensureLoopbackPermission', state);
      var returnResult = {
        state: result,
        msg: state !== 'granted' ? '无法连接到本地设备,请检查浏览器权限设置。' : '',
        permissionFlag: state === 'granted',
      };
      if (state === 'prompt' && options.triggerUserAuthPermission !== false) { // triggerUserAuthPermission: 是否需要触发用户授权
        // 触发权限提示,fetch接口会触发用户授权(在浏览器地址栏最左侧会弹出一个授权小弹窗)
        return fetch('http://localhost/', { mode: 'no-cors' })
          .then(function () {
            return ensureLoopbackPermission({
              triggerUserAuthPermission: false
            });
          })
          .catch(function () {
            return ensureLoopbackPermission({
              triggerUserAuthPermission: false
            });
          });
      }
      return returnResult;
    });
}

这个函数可以在 new Websocket 之前调用,以防用户浏览器没开权限。

有些浏览器可能默认是禁用了本地网络访问的,调用ensureLoopbackPermission函数后的结果可能是denied,此时浏览器是不会显示本地网络访问权限提示的,因此需要在界面上弹出弹窗引导用户手动授权。我写了个插件来引导用户手动授权,效果:

代码:

js 复制代码
var ALLOW_LNA_PERMISSION_IMAGE = '__ALLOW_LNA_PERMISSION_IMAGE__';
var MANUAL_LNA_PERMISSION_CHROME_IMAGE = '__MANUAL_LNA_PERMISSION_CHROME_IMAGE__';
var MANUAL_LNA_PERMISSION_EDGE_IMAGE = '__MANUAL_LNA_PERMISSION_EDGE_IMAGE__';

var OlymLnaPermissionGuide = (function () {
  var overlay = null;
  var styleElement = null;
  var eventsBound = false;

  function getDocument() {
    if (typeof document !== 'undefined' && document) {
      return document;
    }
    if (typeof window !== 'undefined' && window.document) {
      return window.document;
    }
    return null;
  }

  function getWindow() {
    if (typeof window !== 'undefined' && window) {
      return window;
    }
    return null;
  }

  function ensureStyle(doc) {
    if (styleElement || !doc || !doc.head) {
      return styleElement;
    }

    styleElement = doc.createElement('style');
    styleElement.type = 'text/css';
    styleElement.textContent = [
      '.olym-lna-permission-guide-overlay{',
      'position:fixed;',
      'left:0;',
      'top:0;',
      'right:0;',
      'bottom:0;',
      'z-index:9999;',
      'display:none;',
      'padding:20px 0;',
      // 'align-items:center;',
      // 'justify-content:center;',
      'overflow:auto;',
      'background:rgba(0,0,0,0.45);',
      '}',
      '.olym-lna-permission-guide-modal{',
      'position:relative;',
      'box-sizing:border-box;',
      'width:min(720px,calc(100vw - 32px));',
      'max-width:720px;',
      'padding:24px 24px 20px;',
      'border-radius:16px;',
      'margin:0 auto;',
      'background:#fff;',
      'box-shadow:0 18px 48px rgba(15,23,42,0.24);',
      'font-family:Arial,sans-serif;',
      'color:#1f2937;',
      '}',
      '.olym-lna-permission-guide-header{',
      'display:flex;',
      'align-items:center;',
      'justify-content:space-between;',
      'gap:16px;',
      'margin-bottom:22px;',
      '}',
      '.olym-lna-permission-guide-title{',
      'margin:0;',
      'font-size:22px;',
      'font-weight:700;',
      'line-height:1.4;',
      'color:#0f172a;',
      '}',
      '.olym-lna-permission-guide-notice{',
      'margin-bottom:15px;',
      'padding:14px 16px;',
      'border:1px solid #f59e0b;',
      'border-radius:12px;',
      'background:linear-gradient(135deg,#fff7ed 0%,#fef3c7 100%);',
      'color:#9a3412;',
      'font-size:15px;',
      'font-weight:700;',
      'line-height:1.7;',
      '}',
      '.olym-lna-permission-guide-notice-sub{',
      // 'margin:-4px 0 20px;',
      // 'padding:12px 16px 12px 18px;',
      // 'border-left:4px solid #fb923c;',
      // 'border-radius:12px;',
      // 'background:linear-gradient(180deg,#fffdfa 0%,#fff7ed 100%);',
      // 'box-shadow:0 8px 18px rgba(245,158,11,0.10);',
      'margin:0 0 20px 0;',
      'color:#7c2d12;',
      'font-size:14px;',
      'font-weight:600;',
      'line-height:1.5;',
      '}',
      '.olym-lna-permission-guide-close{',
      'appearance:none;',
      'border:0;',
      'background:transparent;',
      'cursor:pointer;',
      'font-size:22px;',
      'line-height:1;',
      'color:#64748b;',
      'padding:0;',
      '}',
      '.olym-lna-permission-guide-body{',
      'display:flex;',
      'flex-direction:column;',
      'gap:18px;',
      '}',
      '.olym-lna-permission-guide-section{',
      'display:flex;',
      'align-items:flex-start;',
      'gap:16px;',
      'padding:18px;',
      'border:1px solid #dbeafe;',
      'border-radius:14px;',
      'background:linear-gradient(180deg,#ffffff 0%,#f8fbff 100%);',
      'box-shadow:0 10px 24px rgba(37,99,235,0.08);',
      '}',
      '.olym-lna-permission-guide-section-images{',
      // 'display:flex;',
      // 'flex-direction:column;',
      // 'gap:12px;',
      // 'flex:0 0 auto;',
      'flex: 0 0 370px;',
      'max-height:290px;',
      'overflow:auto;',
      '}',
      '.olym-lna-permission-guide-section-image{',
      // 'width:360px;',
      // 'height:112px;',
      'max-width:100%;',
      'flex:0 0 auto;',
      'object-fit:cover;',
      'border-radius:10px;',
      'border:1px solid #dbeafe;',
      'background:#fff;',
      'box-shadow:0 6px 18px rgba(15,23,42,0.08);',
      '}',
      '.olym-lna-permission-guide-section-image + .olym-lna-permission-guide-section-image{',
      'margin-top:10px;',
      '}',
      '.olym-lna-permission-guide-section-content{',
      'flex:1 1 auto;',
      '}',
      '.olym-lna-permission-guide-section-title{',
      'margin:0 0 10px;',
      'font-size:17px;',
      'font-weight:700;',
      'line-height:1.6;',
      'color:#1d4ed8;',
      '}',
      '.olym-lna-permission-guide-section-text{',
      'margin:0;',
      'font-size:15px;',
      'line-height:1.7;',
      'color:#334155;',
      '}'
    ].join('');

    doc.head.appendChild(styleElement);
    return styleElement;
  }

  function createGuideSection(doc, label, text, imageSrc) {
    var section = doc.createElement('div');
    var imageWrap = doc.createElement('div');
    var content = doc.createElement('div');
    var title = doc.createElement('div');
    var description = doc.createElement('div');
    var imageList = Array.isArray(imageSrc) ? imageSrc : [imageSrc];
    var image = null;
    var i = 0;

    section.className = 'olym-lna-permission-guide-section';
    imageWrap.className = 'olym-lna-permission-guide-section-images';

    for (i = 0; i < imageList.length; i++) {
      image = doc.createElement('img');
      image.className = 'olym-lna-permission-guide-section-image';
      image.src = imageList[i];
      image.alt = label;
      imageWrap.appendChild(image);
    }

    content.className = 'olym-lna-permission-guide-section-content';

    title.className = 'olym-lna-permission-guide-section-title';
    title.textContent = label;

    description.className = 'olym-lna-permission-guide-section-text';
    description.textContent = text;

    content.appendChild(title);
    content.appendChild(description);
    section.appendChild(imageWrap);
    section.appendChild(content);

    return section;
  }

  function bindEvents(doc) {
    var win = getWindow();

    if (eventsBound) {
      return;
    }

    if (overlay) {
      overlay.addEventListener('click', function (event) {
        if (event && event.target === overlay) {
          hide();
        }
      });
    }

    if (win && typeof win.addEventListener === 'function') {
      win.addEventListener('keydown', function (event) {
        var key = event && event.key;
        var keyCode = event && event.keyCode;

        if (key === 'Escape' || key === 'Esc' || keyCode === 27) {
          hide();
        }
      });
    }

    eventsBound = true;
  }

  function ensureOverlay() {
    var doc = getDocument();
    var modal;
    var header;
    var title;
    var notice;
    var closeButton;
    var body;

    if (!doc) {
      return null;
    }

    ensureStyle(doc);

    if (!overlay) {
      overlay = doc.createElement('div');
      overlay.className = 'olym-lna-permission-guide-overlay';
      overlay.style.display = 'none';
      // overlay.style.alignItems = 'center';
      // overlay.style.justifyContent = 'center';

      modal = doc.createElement('div');
      modal.className = 'olym-lna-permission-guide-modal';

      header = doc.createElement('div');
      header.className = 'olym-lna-permission-guide-header';

      title = doc.createElement('div');
      title.className = 'olym-lna-permission-guide-title';
      title.textContent = '浏览器"本地网络权限"授权操作提示';

      notice = doc.createElement('div');
      notice.className = 'olym-lna-permission-guide-notice';
      notice.textContent = '我们的页面需要访问"本地网络",请根据下方指引进行浏览器授权操作!';

      var noticeSub = doc.createElement('div');
      noticeSub.className = 'olym-lna-permission-guide-notice-sub';
      noticeSub.innerText= '提示:"允许"本地网络访问不会对您的电脑有任何威胁,请放心操作。如"拒绝"或点击关闭按钮,可能导致页面中部分功能无法使用!';

      closeButton = doc.createElement('button');
      closeButton.className = 'olym-lna-permission-guide-close';
      closeButton.type = 'button';
      closeButton.textContent = '×';
      closeButton.addEventListener('click', function () {
        hide();
      });

      header.appendChild(title);
      header.appendChild(closeButton);

      body = doc.createElement('div');
      body.className = 'olym-lna-permission-guide-body';
      body.appendChild(createGuideSection(
        doc,
        '情况一:点击浏览器弹出的"允许"按钮',
        '当浏览器出现图中"本地网络权限"授权弹窗时,直接点击"允许",即可继续完成连接。',
        ALLOW_LNA_PERMISSION_IMAGE
      ));
      body.appendChild(createGuideSection(
        doc,
        '情况二:手动开启本地网络权限',
        '如果您没有看到授权弹窗,需要您按图中所示或到浏览器设置里手动开启本地网络权限后重新尝试。',
        [
          MANUAL_LNA_PERMISSION_CHROME_IMAGE,
          MANUAL_LNA_PERMISSION_EDGE_IMAGE
        ]
      ));

      modal.appendChild(header);
      modal.appendChild(notice);
      modal.appendChild(noticeSub);
      modal.appendChild(body);
      overlay.appendChild(modal);

      bindEvents(doc);
    }

    if (!overlay.parentNode && doc.body) {
      doc.body.appendChild(overlay);
    }

    return overlay;
  }

  function show() {
    var node = ensureOverlay();

    if (!node) {
      return null;
    }

    node.style.display = 'block';
    return node;
  }

  function hide() {
    if (!overlay) {
      return;
    }

    overlay.style.display = 'none';
  }

  function use(Target) {
    if (typeof Target !== 'function') {
      throw new TypeError('Target must be a function');
    }

    // Target.LnaPermissionGuide = LnaPermissionGuide;
    // 自动插入版本号,此段代码请勿移除
    /* [inject version] */
    // 自动插入git信息,此段代码请勿移除
    /* [inject gitInfo] */

    if (typeof Target.showLnaPermissionGuide !== 'function') {
      Target.showLnaPermissionGuide = show;
    }

    if (typeof Target.hideLnaPermissionGuide !== 'function') {
      Target.hideLnaPermissionGuide = hide;
    }

    return Target;
  }

  return {
    use: use,
    show: show,
    hide: hide
  };
})();

if (typeof window !== 'undefined') {
  window.OlymLnaPermissionGuide = OlymLnaPermissionGuide;
}

if (typeof globalThis !== 'undefined') {
  globalThis.OlymLnaPermissionGuide = OlymLnaPermissionGuide;
}

该代码不能拿来就用,需要稍微修改一下,按照指引来修改即可:

  • ALLOW_LNA_PERMISSION_IMAGE 变量的值替换为 allow-lna-permission.png的路径
  • MANUAL_LNA_PERMISSION_CHROME_IMAGE变量的值替换为 lna-permission-guid_chrome.png的路径
  • MANUAL_LNA_PERMISSION_EDGE_IMAGE变量的值替换为lna-permission-guide_edge.png的图片路径

allow-lna-permission.png

lna-permission-guid_chrome.png

lna-permission-guide_edge.png

相关推荐
2301_7736436219 分钟前
ceph镜像
前端·javascript·ceph
To_OC41 分钟前
万字解析《JS语言精粹》之第四章:函数15大核心精髓(JS灵魂核心)
前端·javascript·代码规范
宋拾壹44 分钟前
同时添加多个类目
android·开发语言·javascript
IT知识分享1 小时前
从零开发在线简繁转换工具:OpenCC 实战、避坑经验与方案选型
javascript·python
川冰ICE1 小时前
JavaScript实战④|天气查询应用,调用API与异步处理
javascript·css·css3
微扬嘴角1 小时前
react篇4--setState、LazyLoad和Hooks
前端·javascript·react.js
杨梦馨1 小时前
万级数据表格卡死?Web Worker 一招搞定
前端·javascript·vue.js
用户484526255821 小时前
JavaScript 数组不是数组,是对象
javascript
用户484526255821 小时前
用栈模拟队列:算法题背后的原型链课
javascript
零陵上将军_xdr2 小时前
后端转全栈学习-Day5-JavaScript 基础-3
开发语言·javascript·学习