Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions python/tk_framework_desktopserver/server_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,94 @@ def onConnect(self, response):
logger.info("Connection accepted.")
self._wss_key = response.headers["sec-websocket-key"]

def dataReceived(self, data):
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dataReceived() is not message-framed; Twisted may deliver only part of the HTTP request line/headers in the first call. If the first chunk doesn’t start with b\"OPTIONS \" (e.g., it starts with b\"OP\"), the code will fall through to Autobahn and likely break the handshake. Consider buffering until you have at least a full request line (ending in \\r\\n) and ideally until the end-of-headers marker (\\r\\n\\r\\n) before deciding whether to route to _handle_lna_preflight() vs super().dataReceived().

Copilot uses AI. Check for mistakes.
"""
Override dataReceived to intercept Chrome's Local Network Access preflight
requests before Autobahn's WebSocket handshake processing.

Chrome 147+ sends an HTTP OPTIONS preflight with
Access-Control-Request-Private-Network: true before attempting a WebSocket
connection to a local network address (e.g. localhost).
"""
if data.startswith(b"OPTIONS "):
self._handle_lna_preflight(data)
return
super().dataReceived(data)

Comment on lines +136 to +140
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dataReceived() is not message-framed; Twisted may deliver only part of the HTTP request line/headers in the first call. If the first chunk doesn’t start with b\"OPTIONS \" (e.g., it starts with b\"OP\"), the code will fall through to Autobahn and likely break the handshake. Consider buffering until you have at least a full request line (ending in \\r\\n) and ideally until the end-of-headers marker (\\r\\n\\r\\n) before deciding whether to route to _handle_lna_preflight() vs super().dataReceived().

Suggested change
if data.startswith(b"OPTIONS "):
self._handle_lna_preflight(data)
return
super().dataReceived(data)
if getattr(self, "_initial_http_request_routed", False):
super().dataReceived(data)
return
buffered_data = getattr(self, "_initial_http_header_buffer", b"") + data
self._initial_http_header_buffer = buffered_data
header_end = buffered_data.find(b"\r\n\r\n")
if header_end == -1:
return
header_end += 4
headers = buffered_data[:header_end]
remainder = buffered_data[header_end:]
self._initial_http_request_routed = True
self._initial_http_header_buffer = b""
if headers.startswith(b"OPTIONS "):
self._handle_lna_preflight(headers)
return
super().dataReceived(headers + remainder)

Copilot uses AI. Check for mistakes.
def _handle_lna_preflight(self, data):
"""
Respond to Chrome's Local Network Access (LNA) OPTIONS preflight request.

Chrome 147+ introduced LNA restrictions for WebSockets. When a public-origin
page connects to localhost, Chrome first sends an OPTIONS preflight with
Access-Control-Request-Private-Network: true. Responding with
Access-Control-Allow-Private-Network: true allows the WebSocket upgrade to
proceed without triggering a user permission prompt.
"""
origin = None
is_lna_preflight = False
try:
for line in data.split(b"\r\n"):
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preflight response does not take Access-Control-Request-Method / Access-Control-Request-Headers into account. Many user agents require Access-Control-Allow-Methods to include the requested method and Access-Control-Allow-Headers to include (or correctly wildcard) the requested headers; Access-Control-Allow-Headers: * is not consistently accepted for preflight across implementations/versions. To avoid Chrome still rejecting the preflight, parse Access-Control-Request-Method and Access-Control-Request-Headers and echo/permit those values in the corresponding Access-Control-Allow-* headers.

Copilot uses AI. Check for mistakes.
lower = line.lower()
if lower.startswith(b"origin:"):
origin = line.split(b":", 1)[1].strip().decode(
"utf-8", errors="replace"
)
elif lower.startswith(b"access-control-request-private-network:"):
is_lna_preflight = (
line.split(b":", 1)[1].strip().lower() == b"true"
)
Comment on lines +160 to +163
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preflight response does not take Access-Control-Request-Method / Access-Control-Request-Headers into account. Many user agents require Access-Control-Allow-Methods to include the requested method and Access-Control-Allow-Headers to include (or correctly wildcard) the requested headers; Access-Control-Allow-Headers: * is not consistently accepted for preflight across implementations/versions. To avoid Chrome still rejecting the preflight, parse Access-Control-Request-Method and Access-Control-Request-Headers and echo/permit those values in the corresponding Access-Control-Allow-* headers.

Copilot uses AI. Check for mistakes.
except Exception:
logger.exception("Error parsing LNA preflight headers.")

if not is_lna_preflight:
logger.debug("OPTIONS request missing LNA header, returning 405.")
self.transport.write(
b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\n\r\n"
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a 405 response, RFC-compliant behavior is to include an Allow: header indicating permitted methods (e.g., Allow: GET). Adding it makes the response more standards-aligned and easier to diagnose with intermediaries/tools.

Suggested change
b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\n\r\n"
b"HTTP/1.1 405 Method Not Allowed\r\n"
b"Allow: GET\r\n"
b"Content-Length: 0\r\n"
b"\r\n"

Copilot uses AI. Check for mistakes.
)
self.transport.loseConnection()
return

if not self._is_origin_allowed(origin):
logger.warning("LNA preflight from disallowed origin: %s", origin)
self.transport.write(
b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n"
)
self.transport.loseConnection()
return

logger.debug("Responding to Chrome LNA preflight from origin: %s", origin)
origin_bytes = (origin or "").encode("utf-8")
response = (
b"HTTP/1.1 200 OK\r\n"
b"Access-Control-Allow-Private-Network: true\r\n"
b"Access-Control-Allow-Origin: " + origin_bytes + b"\r\n"
b"Access-Control-Allow-Methods: GET\r\n"
b"Access-Control-Allow-Headers: *\r\n"
b"Content-Length: 0\r\n"
b"\r\n"
)
Comment on lines +185 to +193
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preflight response does not take Access-Control-Request-Method / Access-Control-Request-Headers into account. Many user agents require Access-Control-Allow-Methods to include the requested method and Access-Control-Allow-Headers to include (or correctly wildcard) the requested headers; Access-Control-Allow-Headers: * is not consistently accepted for preflight across implementations/versions. To avoid Chrome still rejecting the preflight, parse Access-Control-Request-Method and Access-Control-Request-Headers and echo/permit those values in the corresponding Access-Control-Allow-* headers.

Copilot uses AI. Check for mistakes.
self.transport.write(response)
self.transport.loseConnection()

def _is_origin_allowed(self, origin):
"""
Check if the given origin is in the list of allowed hosts.

:param origin: Origin URL string from the request headers
(e.g. "https://mysite.shotgunstudio.com").
:returns: True if the origin is allowed, False otherwise.
"""
if not origin:
return False
try:
parsed = urlparse(origin)
origin_host = (parsed.hostname or parsed.netloc).lower()
return origin_host in self.factory.host_aliases
except Exception:
logger.exception("Error validating LNA preflight origin: %s", origin)
return False

def onMessage(self, payload, is_binary):
"""
Called by 'WebSocketServerProtocol' when we receive a message from the websocket.
Expand Down
Loading