From b3905e7e939cec2d51b28dd12f9c8e77b0e69dea Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Mon, 23 Mar 2026 01:12:29 +0200 Subject: [PATCH] fix: recognize host.docker.internal as localhost in Docker deployments (#744) * fix: recognize host.docker.internal as localhost in Docker deployments * chore: remove tunnel-auth test file --- .../web/server/lib/opencode/DOCUMENTATION.md | 1 + .../web/server/lib/opencode/tunnel-auth.js | 102 +++++++++++++++++- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index ed8b9725..843d6f93 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -56,3 +56,4 @@ This module provides OpenCode server integration utilities for the web server ru - All file writes include automatic backup before modification. - Config merging follows priority: custom > project > user. - UI auth uses scrypt for password hashing with constant-time comparison. +- Tunnel auth treats `host.docker.internal` as local-only when the socket remote IP is private/loopback. diff --git a/packages/web/server/lib/opencode/tunnel-auth.js b/packages/web/server/lib/opencode/tunnel-auth.js index aba0dc29..dd6f1f79 100644 --- a/packages/web/server/lib/opencode/tunnel-auth.js +++ b/packages/web/server/lib/opencode/tunnel-auth.js @@ -80,11 +80,107 @@ const normalizeHost = (candidate) => { return trimmed.replace(/:\d+$/, ''); }; -const isLocalHost = (host) => { +const normalizeIpCandidate = (candidate) => { + if (typeof candidate !== 'string') { + return null; + } + + const trimmed = candidate.trim().toLowerCase(); + if (!trimmed) { + return null; + } + + const withoutBrackets = trimmed.startsWith('[') && trimmed.endsWith(']') + ? trimmed.slice(1, -1) + : trimmed; + + const withoutZone = withoutBrackets.split('%')[0]; + if (!withoutZone) { + return null; + } + + if (withoutZone.startsWith('::ffff:')) { + const mappedIpv4 = withoutZone.slice('::ffff:'.length); + if (/^\d+\.\d+\.\d+\.\d+$/.test(mappedIpv4)) { + return mappedIpv4; + } + } + + return withoutZone; +}; + +const getSocketRemoteIp = (req) => { + const remoteAddress = req?.socket?.remoteAddress || req?.connection?.remoteAddress; + return normalizeIpCandidate(remoteAddress); +}; + +const isPrivateOrLoopbackIpv4 = (candidate) => { + const octets = candidate.split('.').map((part) => Number(part)); + if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return false; + } + + const [first, second] = octets; + if (first === 127) { + return true; + } + if (first === 10) { + return true; + } + if (first === 172 && second >= 16 && second <= 31) { + return true; + } + if (first === 192 && second === 168) { + return true; + } + if (first === 169 && second === 254) { + return true; + } + return false; +}; + +const isPrivateOrLoopbackIpv6 = (candidate) => { + if (candidate === '::1') { + return true; + } + + if (candidate.startsWith('fc') || candidate.startsWith('fd')) { + return true; + } + + return candidate.startsWith('fe8') + || candidate.startsWith('fe9') + || candidate.startsWith('fea') + || candidate.startsWith('feb'); +}; + +const isPrivateOrLoopbackIp = (candidate) => { + const normalized = normalizeIpCandidate(candidate); + if (!normalized) { + return false; + } + + if (normalized.includes(':')) { + return isPrivateOrLoopbackIpv6(normalized); + } + + return isPrivateOrLoopbackIpv4(normalized); +}; + +const isLocalHost = (host, req) => { if (!host) { return false; } - return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; + + if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') { + return true; + } + + if (host === 'host.docker.internal') { + return isPrivateOrLoopbackIp(getSocketRemoteIp(req)); + } + + return false; }; const getClientIp = (req) => { @@ -163,7 +259,7 @@ export const createTunnelAuth = () => { return 'tunnel'; } - if (isLocalHost(reqHost)) { + if (isLocalHost(reqHost, req)) { return 'local'; }