Skip to content
Open
Show file tree
Hide file tree
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
22 changes: 19 additions & 3 deletions src/browser_harness/_ipc.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ def _read_port_file(name):
return None, None


def safe_open_write(path, append=False):
"""Securely open a file for writing, refusing to follow symlinks where supported."""
flags = os.O_WRONLY | os.O_CREAT
flags |= os.O_APPEND if append else os.O_TRUNC
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
fd = os.open(str(path), flags, 0o600)
return open(fd, "a" if append else "w")
except OSError:
raise RuntimeError(f"Refusing to write to {path} as it may be a symlink attack.")


def sock_addr(name): # display-only, used in log lines
if not IS_WINDOWS: return str(_sock_path(name))
port, _ = _read_port_file(name)
Expand Down Expand Up @@ -163,14 +176,17 @@ async def serve(name, handler):
global _server_token
if not IS_WINDOWS:
path = str(_sock_path(name))
if os.path.exists(path): os.unlink(path)
try: os.unlink(path)
except OSError: pass
# umask 0o077 makes bind() create the socket as 0600 — no TOCTOU window before chmod.
old_umask = os.umask(0o077)
try: server = await asyncio.start_unix_server(handler, path=path)
try: server = await asyncio.start_unix_server(handler, path=path, limit=1024 * 1024 * 16)
finally: os.umask(old_umask)
_server_token = None
async with server: await asyncio.Event().wait()
return
# Windows TCP loopback: keep the default 64KB limit to prevent unauthenticated
# local clients from forcing large pre-auth memory allocations (DoS).
server = await asyncio.start_server(handler, "127.0.0.1", 0)
port = server.sockets[0].getsockname()[1]
_server_token = secrets.token_hex(32)
Expand All @@ -194,4 +210,4 @@ def expected_token():
def cleanup_endpoint(name): # best-effort; silent if already gone
p = _sock_path(name) if not IS_WINDOWS else port_path(name)
try: p.unlink()
except FileNotFoundError: pass
except OSError: pass
15 changes: 12 additions & 3 deletions src/browser_harness/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ def _load_env_file(p):


def log(msg):
open(LOG, "a").write(f"{msg}\n")
try:
with ipc.safe_open_write(LOG, append=True) as f:
f.write(f"{msg}\n")
except RuntimeError:
pass


async def _silent(coro):
Expand Down Expand Up @@ -405,8 +409,13 @@ def already_running():
if already_running():
print(f"daemon already running on {SOCK}", file=sys.stderr)
sys.exit(0)
open(LOG, "w").close()
open(PID, "w").write(str(os.getpid()))
try:
ipc.safe_open_write(LOG).close()
with ipc.safe_open_write(PID) as f:
f.write(str(os.getpid()))
except RuntimeError as e:
print(f"fatal: {e}", file=sys.stderr)
sys.exit(1)
try:
asyncio.run(main())
except KeyboardInterrupt:
Expand Down