로컬 네트워크 액세스

public 출처 (또는 인트라넷 local 출처)에서 제공되는 웹 애플리케이션이 사용자의 비공개 로컬 네트워크 (예: 192.168.1.x 또는 device.local의 LAN 프린터, IoT 허브, 라우터) 또는 사용자의 머신에서 실행되는 로컬 호환 서비스 (127.0.0.1, [::1], localhost)에 연결되면 브라우저는 로컬 네트워크 액세스 (LNA) 권한 메시지를 통해 연결을 제어합니다.

로컬 네트워크 액세스는 IP 주소 공간 격자 (public → local → loopback)를 기반으로 액세스를 두 개의 세분화된 권한과 Permissions-Policy 토큰으로 분할하여 요청이 덜 비공개적인 주소 공간에서 더 비공개적인 주소 공간으로 교차하는 경우에만 검사를 시행합니다.

  • local-network: public 출처에서 local 주소 공간 (RFC 1918 비공개 IPv4 범위 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 링크 로컬 169.254.0.0/16 / fe80::/10, IPv6 고유 로컬 주소 fc00::/7, .local mDNS 호스트 이름)에 연결할 때 필요합니다.
  • loopback-network: public 또는 local 원본에서 loopback 주소 공간 (127.0.0.0/8, ::1/128 또는 localhost)에 연결할 때 필요합니다.

localhost (127.0.0.1 및 [::1])는 이미 가장 비공개적인 (loopback) 주소 공간에 있으므로 로컬 개발 중에 http://localhost에서 웹 앱을 제공하면 기본적으로 LNA 검사가 트리거되지 않습니다. 공개 서버에 배포하지 않고 로컬에서 LNA 프롬프트 및 권한 상태를 테스트하려면 --ip-address-space-overrides=127.0.0.1:8080=public,[::1]:8080=public Chromium 플래그를 사용하여 로컬 개발 서버의 IPv4 및 IPv6 주소 공간을 public로 재정의하거나 브라우저 사이트 설정에서 Ask, Allow, Block 간에 로컬 네트워크 및 루프백 네트워크를 수동으로 전환합니다.

필수: 보안 컨텍스트 (https://)에서 로컬 또는 루프백 네트워크 요청을 시작하는 모든 웹 애플리케이션을 제공합니다. 로컬 네트워크 액세스 권한은 안전하지 않은 (http://) 최상위 컨텍스트에서 자동으로 거부됩니다.

지원 중단된 비공개 네트워크 액세스 (PNA) 프리플라이트 헤더 (Access-Control-Request-Private-Network: true / Access-Control-Allow-Private-Network: true)를 사용하지 마세요. 로컬 네트워크 액세스는 교차 출처 응답을 읽을 때 클라이언트 측 사용자 권한 프롬프트 (local-network 및 loopback-network)와 표준 CORS 헤더 (Access-Control-Allow-Origin)를 통해 액세스를 적용합니다. PNA 사전 실행은 차단 요구사항으로 적용된 적이 없으며 대체 수단으로 필요하지 않습니다. 이미 Access-Control-Allow-Private-Network: true를 반환하는 기존 서버 또는 기기 펌웨어는 계속해서 그렇게 해도 무방하지만 새 코드에 PNA 처리를 추가하지 마세요.

구현 단계

1. 세분화된 권한 상태 사전 확인 및 사용자 동작에 따른 게이트

권한 상태가 'prompt'인 경우 페이지 로드 (DOMContentLoaded) 시 로컬 네트워크 또는 루프백 요청을 무조건 실행하지 마세요. 또한 서비스 워커 또는 공유 워커(Document가 연결되어 있지 않으며 권한이 아직 'granted'가 아닌 경우 즉시 실패함)에서 첫 번째 권한 트리거 요청을 시작하지 마세요.

대신 먼저 navigator.permissions.query()에서 'local-network' 또는 'loopback-network'를 쿼리합니다.

  • state === 'granted'인 경우 자동으로 연결합니다.
  • state === 'prompt'인 경우 앱에 로컬 기기 또는 호환 앱에 대한 액세스 권한이 필요한 이유를 설명하는 명확한 UI를 표시하고 명시적인 사용자 동작 (예: 버튼 클릭)에서 요청을 트리거합니다.
  • state === 'denied'인 경우 브라우저 사이트 설정에서 로컬 네트워크 또는 루프백 액세스를 다시 사용 설정하는 방법을 사용자에게 안내하는 실행 가능한 해결 UI를 렌더링합니다.
/**
 * Queries the granular Local Network Access permission for the target space.
 * @param {'local-network' | 'loopback-network'} permissionName
 */
export async function checkNetworkPermission(permissionName) {
  const status = await navigator.permissions.query({ name: permissionName });

  // Keep UI synchronized if the user changes permission in browser site settings
  status.addEventListener('change', () => {
    updatePermissionUI(permissionName, status.state);
  });

  updatePermissionUI(permissionName, status.state);
  return status.state;
}

function updatePermissionUI(permissionName, state) {
  const statusBadge = document.querySelector(`[data-permission-status="${permissionName}"]`);
  const remediationBox = document.querySelector(`[data-remediation="${permissionName}"]`);
  if (statusBadge) {
    statusBadge.textContent = state;
  }
  if (remediationBox) {
    // Show remediation instructions whenever permission is explicitly denied
    remediationBox.hidden = state !== 'denied';
  }
}

2. fetch() 및 Request에서 targetAddressSpace 선언

https:// 페이지에서 로컬 네트워크 또는 루프백 인터페이스의 http:// 엔드포인트를 요청하는 경우 fetch() 또는 Request 옵션에 targetAddressSpace: 'local' 또는 targetAddressSpace: 'loopback'를 지정합니다.

targetAddressSpace를 명시적으로 설정하면 다음과 같은 두 가지 중요한 작업이 수행됩니다.

  1. 혼합 콘텐츠 예외: 혼합 콘텐츠 검사는 DNS 변환 전에 실행됩니다. targetAddressSpace: 'local' (또는 'loopback')를 선언하면 DNS 변환 전에 브라우저에 http:// URL (로컬 IP로 변환되는 맞춤 DNS 호스트 이름 포함)이 로컬/루프백 엔드포인트를 타겟팅하며 사용자가 권한을 부여하면 혼합 콘텐츠 차단에서 제외되어야 한다고 알립니다.
  2. 주소 공간 확인: DNS 변환 후 브라우저는 변환된 IP 주소가 실제로 선언된 targetAddressSpace에 속하는지 확인합니다. targetAddressSpace: 'local'이 있는 호스트 이름이 예기치 않게 public IP 주소로 확인되면 브라우저는 TypeError 네트워크 오류와 함께 요청을 즉시 중단합니다.
/**
 * Connects to a local network device (e.g., LAN printer or IoT hub) over HTTP.
 * Example endpoint URL is illustrative; replace with your device's address.
 */
export async function fetchLocalDeviceStatus(deviceUrl = 'http://192.168.1.100:8080/api/status') {
  const request = new Request(deviceUrl, {
    method: 'GET',
    mode: 'cors',
    // MANDATORY: Declare 'local' when targeting RFC 1918 / .local LAN addresses
    targetAddressSpace: 'local',
  });

  const response = await fetch(request);
  if (!response.ok) {
    throw new Error(`Device responded with HTTP ${response.status}`);
  }
  return response.json();
}

/**
 * Connects to a local companion daemon running on the user's loopback interface.
 * Example loopback port is illustrative; replace with your service's port.
 */
export async function fetchLoopbackDaemon(daemonUrl = 'http://127.0.0.1:45678/health') {
  const request = new Request(daemonUrl, {
    method: 'GET',
    mode: 'cors',
    // MANDATORY: Declare 'loopback' when targeting 127.0.0.1, [::1], or localhost
    targetAddressSpace: 'loopback',
  });

  const response = await fetch(request);
  if (!response.ok) {
    throw new Error(`Companion app responded with HTTP ${response.status}`);
  }
  return response.json();
}

3. WebSocket 및 WebTransport를 통한 실시간 소통

로컬 네트워크 액세스 제한은 WebSocket 및 WebTransport 연결도 제어합니다.

  • WebSocket: WebSocket(url, protocols) 생성자는 하위 프로토콜 문자열 또는 배열만 두 번째 인수로 허용합니다. { targetAddressSpace } 옵션 객체를 전달하지 마세요. SyntaxError가 발생합니다. 대신 브라우저는 DNS 변환 전에 WebSocket URL을 검사합니다. URL 호스트가 구문적으로 로컬 또는 루프백으로 인식 가능한 경우(특히 비공개 또는 루프백 IP 리터럴(ws://192.168.1.100:8080, ws://127.0.0.1:8080, ws://[::1]:8080), localhost(*.localhost) 또는 .local mDNS 호스트 이름(ws://device.local:8080)) https:// 페이지에서 시작된 ws:// 연결은 혼합 콘텐츠 차단에서 제외되며 local-network 또는 loopback-network 권한 프롬프트 뒤에 제어됩니다. 로컬 엔드포인트가 커스텀 비.local DNS 호스트 이름을 통해 주소가 지정된 경우 wss://를 통해 연결합니다.
  • WebTransport: WebTransport에는 https:// (TLS를 사용하는 QUIC를 통한 HTTP/3 또는 로컬 자체 서명 인증서의 경우 serverCertificateHashes)가 엄격하게 필요하므로 혼합 콘텐츠 차단을 트리거하지 않으며 targetAddressSpace 옵션을 허용하지 않습니다. 대신 브라우저가 확인된 IP 주소 공간을 자동으로 확인하고 local-network 또는 loopback-network 권한 프롬프트 뒤에 transport.ready 프로미스를 게이트합니다.
/**
 * Opens a WebSocket connection to a local network device (IP literal, .local, or localhost).
 */
export function openLocalWebSocket(wsUrl = 'ws://192.168.1.100:8080/stream') {
  // Pass only subprotocol string(s) as the second argument (never an options object).
  // Browsers exempt ws:// from Mixed Content when the host is a local/loopback IP literal,
  // .local, or localhost, and gate the connection on 'local-network' or 'loopback-network'.
  const socket = new WebSocket(wsUrl, ['v1.telemetry']);
  return socket;
}

/**
 * Opens a WebTransport session to a local network endpoint using a pinned self-signed certificate hash.
 */
export async function openLocalWebTransport(wtUrl = 'https://192.168.1.100:4433/wt', certSha256Bytes) {
  const transport = new WebTransport(wtUrl, {
    serverCertificateHashes: [
      {
        algorithm: 'sha-256',
        value: certSha256Bytes,
      },
    ],
  });

  // transport.ready resolves only after the user grants the 'local-network' permission
  // and rejects with WebTransportError if the permission is denied.
  await transport.ready;
  return transport;
}

4. Permissions-Policy를 통해 교차 출처 iframe에 액세스 권한 위임

기본적으로 local-network 및 loopback-network에는 'self'의 기본 허용 목록이 있어 교차 출처 <iframe> 삽입이 로컬 또는 루프백 요청을 수행하지 못하도록 차단합니다. 삽입된 교차 출처 <iframe> (예: 하드웨어 설정 위젯 또는 로컬 에이전트와 통신하는 SSO 프레임)가 로컬 또는 루프백 요청을 시작하도록 허용하려면 allow 속성 또는 HTTP Permissions-Policy 헤더에서 세분화된 권한을 명시적으로 위임합니다.

<!-- Delegate granular local-network and loopback-network permissions to the embedded iframe -->
<iframe
  src="https://setup.partner.example.com/device-bridge"
  allow="local-network; loopback-network"
  title="Local Device Bridge">
</iframe>

iframe의 응답이 아닌 최상위 (삽입) 문서의 응답에 Permissions-Policy 헤더를 전송합니다.

Permissions-Policy: local-network=(self "https://setup.partner.example.com"), loopback-network=(self "https://setup.partner.example.com")

대체 전략

기준 타겟이 로컬 네트워크 액세스 권한 및 targetAddressSpace 옵션을 지원하지 않는 경우 점진적 대체가 포함된 기능 감지를 구현합니다.

  1. 권한 쿼리 대체: navigator.permissions.query({ name: permissionName })를 try / catch로 래핑합니다. 'local-network' 또는 'loopback-network'가 인식되지 않는 PermissionName이므로 브라우저에서 TypeError가 발생하는 경우 상태를 'prompt'로 처리하여 명시적인 사용자 클릭 시 요청이 계속 실행되도록 합니다 (연결 시도 자체가 LNA가 지원되는 브라우저 프롬프트를 트리거함).
  2. 레거시 결합 권한 이름 { name: 'local-network-access' }을 쿼리하지 마세요. 이전 Chrome 버전에서는 이 쿼리로 인해 렌더러 프로세스가 비정상 종료되며 try / catch에서 이를 방지할 수 없습니다. 최신 Chrome 버전에서는 'local-network-access'을 세분화된 권한의 기존 별칭으로만 취급합니다.
  3. 원활한 거부 처리: 항상 fetch(), WebSocket 오류 이벤트, transport.ready를 TypeError/연결 실패를 포착하고 명확한 해결 단계 (로컬 기기의 전원이 켜져 있고 동일한 네트워크에 연결되어 있으며 브라우저 권한에서 허용되는지 확인)를 제시하는 오류 핸들러로 래핑합니다.
/**
 * Cross-browser permission check for the granular LNA permissions.
 * Never falls back to querying 'local-network-access', which crashes older Chrome versions.
 * @param {'local-network' | 'loopback-network'} permissionName
 */
export async function queryLnaPermissionSafe(permissionName) {
  if (!navigator.permissions?.query) {
    return 'prompt';
  }
  try {
    const status = await navigator.permissions.query({ name: permissionName });
    return status.state;
  } catch {
    // Unrecognized permission name; proceed on user gesture
    return 'prompt';
  }
}