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,.localmDNS 호스트 이름)에 연결할 때 필요합니다.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를 명시적으로 설정하면 다음과 같은 두 가지 중요한 작업이 수행됩니다.
- 혼합 콘텐츠 예외: 혼합 콘텐츠 검사는 DNS 변환 전에 실행됩니다.
targetAddressSpace: 'local'(또는'loopback')를 선언하면 DNS 변환 전에 브라우저에http://URL (로컬 IP로 변환되는 맞춤 DNS 호스트 이름 포함)이 로컬/루프백 엔드포인트를 타겟팅하며 사용자가 권한을 부여하면 혼합 콘텐츠 차단에서 제외되어야 한다고 알립니다. - 주소 공간 확인: DNS 변환 후 브라우저는 변환된 IP 주소가 실제로 선언된
targetAddressSpace에 속하는지 확인합니다.targetAddressSpace: 'local'이 있는 호스트 이름이 예기치 않게publicIP 주소로 확인되면 브라우저는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 변환 전에WebSocketURL을 검사합니다. URL 호스트가 구문적으로 로컬 또는 루프백으로 인식 가능한 경우(특히 비공개 또는 루프백 IP 리터럴(ws://192.168.1.100:8080,ws://127.0.0.1:8080,ws://[::1]:8080),localhost(*.localhost) 또는.localmDNS 호스트 이름(ws://device.local:8080))https://페이지에서 시작된ws://연결은 혼합 콘텐츠 차단에서 제외되며local-network또는loopback-network권한 프롬프트 뒤에 제어됩니다. 로컬 엔드포인트가 커스텀 비.localDNS 호스트 이름을 통해 주소가 지정된 경우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 옵션을 지원하지 않는 경우 점진적 대체가 포함된 기능 감지를 구현합니다.
- 권한 쿼리 대체:
navigator.permissions.query({ name: permissionName })를try / catch로 래핑합니다.'local-network'또는'loopback-network'가 인식되지 않는PermissionName이므로 브라우저에서TypeError가 발생하는 경우 상태를'prompt'로 처리하여 명시적인 사용자 클릭 시 요청이 계속 실행되도록 합니다 (연결 시도 자체가 LNA가 지원되는 브라우저 프롬프트를 트리거함). - 레거시 결합 권한 이름
{ name: 'local-network-access' }을 쿼리하지 마세요. 이전 Chrome 버전에서는 이 쿼리로 인해 렌더러 프로세스가 비정상 종료되며try / catch에서 이를 방지할 수 없습니다. 최신 Chrome 버전에서는'local-network-access'을 세분화된 권한의 기존 별칭으로만 취급합니다. - 원활한 거부 처리: 항상
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';
}
}