Files

599 lines
25 KiB
Python

"""Explicit LXC icon lookup. No guest execution, background scan or app writes.
Untrusted HTTP and image parsing run in an unprivileged bounded child. Only
the target CT's pinned network namespace may be used for HTTP. Cache objects are
immutable: fetching a draft cannot overwrite a registered application's icon.
"""
from __future__ import annotations
import base64
import contextlib
import fcntl
import hashlib
import io
import ipaddress
import json
import os
from pathlib import Path
import re
import signal
import socket
import ssl
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from html.parser import HTMLParser
CACHE = Path('/etc/proxmenux/apps/logo-cache')
APPS = Path('/etc/proxmenux/apps')
CONFIG = Path('/etc/pve/lxc')
PROC = Path('/proc')
MAX_BYTES = 256 * 1024
MAX_ENTRIES = 32
MAX_DIMENSION = 2048
MAX_PIXELS = 1024 * 1024
WORKER_TIMEOUT = 8
UNUSED_TTL = 24 * 3600
SLOTS = threading.BoundedSemaphore(4)
DECODERS = threading.BoundedSemaphore(4)
TOKEN = re.compile(r'^[a-f0-9]{32}$')
URL = re.compile(r'^/api/vms/([1-9][0-9]*)/apps/logo-cache/([a-f0-9]{32})$')
MIMES = {'image/png', 'image/jpeg', 'image/gif', 'image/webp',
'image/x-icon', 'image/vnd.microsoft.icon', 'image/svg+xml'}
RASTER_FORMATS = {'image/png': 'PNG', 'image/jpeg': 'JPEG', 'image/gif': 'GIF',
'image/webp': 'WEBP', 'image/x-icon': 'ICO', 'image/vnd.microsoft.icon': 'ICO'}
class IconError(ValueError):
def __init__(self, message, code='local_logo_unavailable'):
super().__init__(message)
self.code = code
def valid_url(vmid, value):
match = URL.fullmatch(value) if isinstance(value, str) else None
return bool(match and vmid is not None and int(match[1]) == int(vmid))
def _private_ip(value):
try:
address = ipaddress.ip_address(value)
networks = ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16') if address.version == 4 else ('fc00::/7',)
return any(address in ipaddress.ip_network(n) for n in networks)
except ValueError:
return False
def _run(args):
try:
result = subprocess.run(args, capture_output=True, text=True, timeout=3)
except (OSError, subprocess.SubprocessError) as exc:
raise IconError('container address unavailable') from exc
if result.returncode:
raise IconError('container address unavailable')
return result.stdout
def _origin(vmid, payload):
if (not isinstance(payload, dict) or set(payload) - {'port', 'scheme'}
or type(payload.get('port')) is not int or not 1 <= payload['port'] <= 65535
or payload.get('scheme') not in ('http', 'https')):
raise IconError('select a valid web port and scheme')
try:
config = (CONFIG / f'{int(vmid)}.conf').read_text().split('\n[', 1)[0]
except OSError as exc:
raise IconError('container network unavailable') from exc
if (not re.search(r'^net\d+:', config, re.M) or 'proxmenux/host-monitor' in config
or re.search(r'^lxc\.net\.', config, re.M)):
raise IconError('container needs an isolated network')
if _run(['/usr/sbin/pct', 'status', str(vmid)]).strip() != 'status: running':
raise IconError('container is not running', 'local_logo_stopped')
ips = _run(['lxc-info', '-n', str(vmid), '-iH']).split()
try:
host_data = json.loads(_run(['ip', '-json', 'address', 'show']))
host_ips = {str(ipaddress.ip_address(a['local'])) for interface in host_data
for a in interface.get('addr_info', []) if 'local' in a}
except (ValueError, TypeError, KeyError) as exc:
raise IconError('host address check unavailable') from exc
# Preserve lxc-info's reported order (CT215: eth0 then Docker bridges).
# Unordered set iteration selected an internal bridge at random. Keep
# runtime order within each address family, including when deduplicating.
addresses = list(dict.fromkeys(str(ipaddress.ip_address(ip)) for ip in ips if _private_ip(ip)))
addresses.sort(key=lambda ip: ':' in ip)
addresses = [ip for ip in addresses if ip not in host_ips]
if not addresses:
raise IconError('container has no isolated private address')
host = addresses[0]
return f"{payload['scheme']}://{'[' + host + ']' if ':' in host else host}:{payload['port']}"
def _init_pid(vmid):
value = _run(['lxc-info', '-n', str(vmid), '-pH']).strip()
if not re.fullmatch(r'[1-9][0-9]{0,9}', value) or int(value) <= 1:
raise IconError('container init process unavailable')
return int(value)
def _process_start(pid):
# comm may contain spaces and parentheses; field 22 follows the last ')'.
value = (PROC / str(pid) / 'stat').read_text().rsplit(')', 1)[1].split()[19]
if not value.isdecimal():
raise IconError('container process identity unavailable')
return value
def _ns_id(info):
return info.st_dev, info.st_ino
def _network_unchanged(vmid, identity):
pid, started, namespace = identity
return (_init_pid(vmid) == pid and _process_start(pid) == started
and _ns_id((PROC / str(pid) / 'ns/net').stat()) == namespace)
@contextlib.contextmanager
def _network(vmid):
"""Pin nsfs before launching; revalidate PID/start time to reject PID reuse."""
fd = None
try:
pid = _init_pid(vmid)
started = _process_start(pid)
fd = os.open(PROC / str(pid) / 'ns/net', os.O_RDONLY | os.O_CLOEXEC)
identity = pid, started, _ns_id(os.fstat(fd))
if (identity[2] == _ns_id((PROC / 'self/ns/net').stat())
or not _network_unchanged(vmid, identity)):
raise IconError('container network is not isolated or changed')
yield fd, identity
except (OSError, IndexError, ValueError) as exc:
raise IconError('container network unavailable') from exc
finally:
if fd is not None:
os.close(fd)
def _owns_address(value):
"""Called in the CT network namespace, never trust lxc-info as a target."""
try:
interfaces = json.loads(_run(['/usr/sbin/ip', '-json', 'address', 'show']))
owned = {str(ipaddress.ip_address(a['local'])) for interface in interfaces
for a in interface.get('addr_info', []) if 'local' in a}
return str(ipaddress.ip_address(value)) in owned
except (ValueError, TypeError, KeyError, AttributeError) as exc:
raise IconError('container address ownership unavailable') from exc
def _same_origin(left, right):
try:
a, b = urllib.parse.urlsplit(left), urllib.parse.urlsplit(right)
return (a.username is None and b.username is None and a.password is None and b.password is None
and (a.scheme, a.hostname, a.port or (443 if a.scheme == 'https' else 80))
== (b.scheme, b.hostname, b.port or (443 if b.scheme == 'https' else 80)))
except ValueError:
return False
class _Redirect(urllib.request.HTTPRedirectHandler):
def __init__(self, origin, page):
self.origin, self.remaining = origin, int(page)
def redirect_request(self, req, fp, code, msg, headers, newurl):
if not self.remaining or not _same_origin(self.origin, newurl):
raise IconError('redirect outside selected application')
self.remaining -= 1
return super().redirect_request(req, fp, code, msg, headers, newurl)
def _open(url, page=False):
# Deliberately do not inherit HTTP(S)_PROXY or credentials/cookies.
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}),
_Redirect(url, page), urllib.request.HTTPSHandler(context=context))
return opener.open(urllib.request.Request(url, headers={
'User-Agent': 'ProxMenux-Monitor/local-icon', 'Accept-Encoding': 'identity',
}), timeout=3)
def _read(response):
body = response.read(MAX_BYTES + 1)
if not body or len(body) > MAX_BYTES:
raise IconError('empty or oversized icon response')
return body
class _Links(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.links = []
def handle_starttag(self, tag, attrs):
values = dict(attrs)
if tag.lower() == 'link' and set((values.get('rel') or '').lower().split()) & {'icon', 'apple-touch-icon'}:
self.links.append(values.get('href') or '')
def _svg(body):
from defusedxml import ElementTree as safe_xml
from xml.etree import ElementTree as xml
try:
original = safe_xml.fromstring(body, forbid_dtd=True, forbid_entities=True, forbid_external=True)
except Exception as exc:
raise IconError('invalid SVG') from exc
ns = 'http://www.w3.org/2000/svg'
tags = {'svg', 'g', 'defs', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline',
'polygon', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'title', 'desc', 'image'}
attrs = {'viewBox', 'width', 'height', 'x', 'y', 'x1', 'x2', 'y1', 'y2', 'cx', 'cy',
'r', 'rx', 'ry', 'd', 'points', 'fill', 'stroke', 'stroke-width', 'opacity',
'fill-opacity', 'stroke-opacity', 'fill-rule', 'clip-rule', 'stroke-linecap',
'stroke-linejoin', 'stroke-miterlimit', 'transform', 'id', 'offset',
'stop-color', 'stop-opacity', 'gradientUnits', 'gradientTransform', 'fx', 'fy', 'clip-path'}
if original.tag not in ('svg', '{' + ns + '}svg'):
raise IconError('invalid SVG root')
count = 0
images = 0
def clean(node, depth=0):
nonlocal count, images
count += 1
if depth > 32 or count > 2048:
raise IconError('SVG complexity limit')
name = node.tag.removeprefix('{' + ns + '}')
if name not in tags:
return None
embedded = None
if name == 'image':
refs = {value for key, value in node.attrib.items()
if key in ('href', '{http://www.w3.org/1999/xlink}href')}
if len(refs) != 1:
return None
match = re.fullmatch(r'data:(image/(?:png|jpeg|gif|webp|x-icon|vnd\.microsoft\.icon));base64,([A-Za-z0-9+/=]+)', refs.pop())
if not match:
return None # No external resource or recursive SVG data URI.
images += 1
if images > 1:
raise IconError('SVG embedded image limit')
try:
raster = base64.b64decode(match[2], validate=True)
except ValueError as exc:
raise IconError('invalid embedded image') from exc
raster, _mime = _normalize(raster, match[1])
embedded = 'data:image/png;base64,' + base64.b64encode(raster).decode()
out = xml.Element('{' + ns + '}' + name)
for key, value in node.attrib.items():
if key not in attrs or len(value) > 32768:
continue
# Only local fragment paint/clip references; no CSS, href, events,
# foreign namespaces, entities, animation, filters or use.
# A single validated raster data URI is installed separately below.
if re.search(r'url\s*\(', value, re.I):
if not re.fullmatch(r'url\(#[A-Za-z_][\w.-]*\)', value):
continue
elif any(char in value for char in ('\\', ':', '<', '>', '&')):
continue
if key in ('width', 'height') and name == 'svg':
if not re.fullmatch(r'\d+(?:\.\d+)?(?:px)?', value) or not 0 < float(value.removesuffix('px')) <= MAX_DIMENSION:
continue
out.set(key, value)
if embedded:
out.set('href', embedded)
if name in ('title', 'desc'):
out.text = (node.text or '')[:512]
for child in node:
cleaned = clean(child, depth + 1)
if cleaned is not None:
out.append(cleaned)
return out
result = clean(original)
def has_drawing(node):
name = node.tag.removeprefix('{' + ns + '}')
if name in ('defs', 'clipPath'):
return False
return name in {'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'image'} or any(has_drawing(child) for child in node)
if not has_drawing(result):
raise IconError('SVG contains no supported drawing')
xml.register_namespace('', ns)
output = xml.tostring(result, encoding='utf-8')
if len(output) > MAX_BYTES:
raise IconError('normalized SVG too large')
return output, 'image/svg+xml'
def normalize(body, mime):
if not isinstance(body, bytes) or not 0 < len(body) <= MAX_BYTES or mime not in MIMES:
raise IconError('invalid icon type or size')
# Cache GETs, including legacy data, use the same bounded isolation even
# when the Monitor itself has been configured to run without root.
if not DECODERS.acquire(timeout=WORKER_TIMEOUT):
raise IconError('local icon decoder busy')
try:
return _decode_worker(body, mime)
finally:
DECODERS.release()
def _normalize(body, mime):
if os.geteuid() == 0:
raise IconError('image decoding as root is forbidden')
if not isinstance(body, bytes) or not 0 < len(body) <= MAX_BYTES or mime not in MIMES:
raise IconError('invalid icon type or size')
if mime == 'image/svg+xml':
return _svg(body)
from PIL import Image
try:
with Image.open(io.BytesIO(body), formats=[RASTER_FORMATS[mime]]) as picture:
if picture.format != RASTER_FORMATS[mime]:
raise IconError('image format does not match content type')
w, h = picture.size
if not 0 < w <= MAX_DIMENSION or not 0 < h <= MAX_DIMENSION or w * h > MAX_PIXELS:
raise IconError('image dimensions too large')
picture.load()
out = io.BytesIO()
converted = picture.convert('RGBA')
converted.info.clear()
converted.save(out, format='PNG')
result = out.getvalue()
if len(result) > MAX_BYTES:
raise IconError('normalized icon too large')
return result, 'image/png'
except IconError:
raise
except Exception as exc:
raise IconError('invalid image data') from exc
def _fetch(origin):
parsed = urllib.parse.urlsplit(origin)
if (parsed.scheme not in ('http', 'https') or not _private_ip(parsed.hostname or '')
or parsed.username is not None or parsed.password is not None or parsed.path
or parsed.query or parsed.fragment or not parsed.port):
raise IconError('invalid local origin')
if not _owns_address(parsed.hostname):
raise IconError('selected address does not belong to this container')
with _open(origin + '/', page=True) as response:
final = response.geturl()
if not _same_origin(origin, final) or response.headers.get_content_type() not in ('text/html', 'application/xhtml+xml'):
raise IconError('invalid application page')
document = _read(response)
parser = _Links()
parser.feed(document.decode('utf-8', errors='replace'))
links = [urllib.parse.urljoin(final, link) for link in parser.links if link]
candidates = [link for link in links if _same_origin(origin, link)]
if links and not candidates:
raise IconError('external icon not permitted')
icon_url = candidates[0] if candidates else origin + '/favicon.ico'
with _open(icon_url) as response:
if response.geturl() != icon_url:
raise IconError('icon redirect not permitted')
return _normalize(_read(response), response.headers.get_content_type())
def _worker_command(arguments, network_fd):
# No guest binaries, rootfs, environment, user namespace or mount namespace.
# setpriv execs the host Python only after dropping credentials/capabilities.
command = ['/usr/bin/setpriv', '--no-new-privs']
if os.geteuid() == 0:
command += ['--reuid=65534', '--regid=65534', '--clear-groups',
'--bounding-set=-all', '--inh-caps=-all', '--ambient-caps=-all']
command += ['--', sys.executable, '-B', str(Path(__file__).resolve()), *arguments]
if network_fd is not None:
return ['/usr/bin/nsenter', f'--net=/proc/self/fd/{network_fd}', '--no-fork', '--', *command]
if os.geteuid() == 0:
# Legacy/cache decoding cannot make any network request, even if a
# decoder is compromised. No CT probe is needed for a cache-only GET.
return ['/usr/bin/unshare', '--net', '--', *command]
return command
def _run_worker(arguments, *, network_fd=None, data=None):
process = None
try:
with tempfile.TemporaryFile() as output:
process = subprocess.Popen(_worker_command(arguments, network_fd),
stdin=subprocess.PIPE if data is not None else subprocess.DEVNULL,
stdout=output, stderr=subprocess.DEVNULL, cwd='/', start_new_session=True,
pass_fds=() if network_fd is None else (network_fd,))
process.communicate(input=data, timeout=WORKER_TIMEOUT)
output.seek(0)
raw = output.read(MAX_BYTES * 2 + 1)
if process.returncode or len(raw) > MAX_BYTES * 2:
raise IconError('local icon unavailable')
response = json.loads(raw)
body = base64.b64decode(response['body'], validate=True)
if not body or len(body) > MAX_BYTES or response['mime'] not in ('image/png', 'image/svg+xml'):
raise IconError('invalid image worker response')
return body, response['mime']
except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError) as exc:
raise IconError('local icon unavailable') from exc
finally:
if process is not None:
# The nsenter/setpriv chain execs without forking; also terminate
# any descendants a faulty decoder could have left behind.
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
def _worker(origin, network_fd):
return _run_worker(['--fetch', origin, str(network_fd)], network_fd=network_fd)
def _decode_worker(body, mime):
data = json.dumps({'body': base64.b64encode(body).decode(), 'mime': mime}).encode()
return _run_worker(['--decode'], data=data)
def _assert_worker():
fields = dict(line.split(':', 1) for line in Path('/proc/self/status').read_text().splitlines() if ':' in line)
if (os.geteuid() == 0 or os.getegid() == 0 or 0 in os.getgroups()
or fields.get('NoNewPrivs', '').strip() != '1'
or any(int(fields.get(key, '1').strip(), 16) for key in ('CapEff', 'CapPrm', 'CapAmb'))):
raise IconError('image worker is not isolated')
@contextlib.contextmanager
def _cache_lock(vmid):
directory = CACHE / str(int(vmid))
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
with open(directory / '.lock', 'a') as handle:
deadline = time.monotonic() + 2
while True:
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
if time.monotonic() >= deadline:
raise IconError('local icon cache busy')
time.sleep(0.02)
yield directory
def _references(sidecar):
apps = sidecar['apps']
if not isinstance(apps, list):
raise ValueError('invalid apps')
refs = {a.get('logo_url') for a in apps}
refs.update(p.get('logo_url') for a in apps for p in a.get('ports', []))
return refs
@contextlib.contextmanager
def protect_references(vmid, sidecar):
"""Serialize persistence against GC; an expired draft cannot save a dead URL.
Existing missing references are preserved so unrelated checks/edits can
still save. Only newly introduced local URLs require a readable object.
Callers hold this lock through their atomic sidecar replacement.
"""
refs = {url for url in _references(sidecar) if valid_url(vmid, url)}
if not refs:
yield
return
with _cache_lock(vmid):
try:
previous = _references(json.loads((APPS / f'{vmid}.json').read_text()))
except FileNotFoundError:
previous = set()
except (ValueError, TypeError, KeyError, AttributeError) as exc:
raise IconError('cannot verify saved icon references') from exc
for url in refs - previous:
if read(vmid, url.rsplit('/', 1)[1]) is None:
raise IconError('cached icon expired; find the icon again')
yield
def _prune(vmid, directory):
# Read failure means unknown references: retain everything.
try:
sidecar = json.loads((APPS / f'{vmid}.json').read_text())
refs = _references(sidecar)
except FileNotFoundError:
refs = set()
except (ValueError, KeyError, TypeError, OSError, AttributeError):
return
for path in directory.glob('*.icon'):
url = f'/api/vms/{vmid}/apps/logo-cache/{path.stem}'
if url not in refs and time.time() - path.stat().st_mtime > UNUSED_TTL:
path.unlink()
def store(vmid, body, mime):
token = hashlib.sha256(str(vmid).encode() + b'\0' + mime.encode() + b'\0' + body).hexdigest()[:32]
with _cache_lock(vmid) as directory:
dest = directory / (token + '.icon')
if dest.exists():
# Renew the draft's lease without changing immutable image bytes.
# Re-finding an old orphan must give the editor a full save window.
os.utime(dest, None)
else:
_prune(vmid, directory)
if len(list(directory.glob('*.icon'))) >= MAX_ENTRIES:
raise IconError('local icon cache is full')
tmp = None
try:
with tempfile.NamedTemporaryFile(mode='w', dir=directory, delete=False) as handle:
tmp = handle.name
json.dump({'mime': mime, 'body': base64.b64encode(body).decode()}, handle)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, dest)
finally:
if tmp and os.path.exists(tmp):
os.unlink(tmp)
return f'/api/vms/{vmid}/apps/logo-cache/{token}'
def discover(vmid, payload):
if not SLOTS.acquire(blocking=False):
raise IconError('local icon lookup busy')
try:
origin = _origin(vmid, payload)
with _network(vmid) as (network_fd, identity):
body, mime = _worker(origin, network_fd)
if _origin(vmid, payload) != origin or not _network_unchanged(vmid, identity):
raise IconError('container address or network changed')
return {'logo_url': store(vmid, body, mime)}
except OSError as exc:
raise IconError('could not cache the icon') from exc
finally:
SLOTS.release()
def read(vmid, token):
"""Cache-only read, including validation of older testhost cache objects."""
if not TOKEN.fullmatch(token):
return None
directory = CACHE / str(int(vmid))
try:
path = directory / (token + '.icon')
if path.exists():
with path.open('rb') as handle:
raw = handle.read(MAX_BYTES * 2 + 1)
if len(raw) > MAX_BYTES * 2:
return None
data = json.loads(raw)
body, mime = base64.b64decode(data['body'], validate=True), data['mime']
else:
# Compatibility with earlier local builds; never serve legacy raw SVG.
with (directory / (token + '.json')).open('rb') as handle:
meta = json.loads(handle.read(4096))
with (directory / (token + '.bin')).open('rb') as handle:
body = handle.read(MAX_BYTES + 1)
mime = meta['content_type']
return normalize(body, mime)
except (OSError, ValueError, KeyError, TypeError, ImportError):
return None
if __name__ == '__main__':
import resource
resource.setrlimit(resource.RLIMIT_AS, (512 * 1024 * 1024,) * 2)
resource.setrlimit(resource.RLIMIT_CPU, (4, 4))
resource.setrlimit(resource.RLIMIT_FSIZE, (MAX_BYTES * 2, MAX_BYTES * 2))
try:
_assert_worker()
if len(sys.argv) == 4 and sys.argv[1] == '--fetch':
fd = int(sys.argv[3])
if _ns_id(os.fstat(fd)) != _ns_id(Path('/proc/self/ns/net').stat()):
raise IconError('image worker network mismatch')
os.close(fd)
body, mime = _fetch(sys.argv[2])
elif len(sys.argv) == 2 and sys.argv[1] == '--decode':
raw = sys.stdin.buffer.read(MAX_BYTES * 2 + 1)
if len(raw) > MAX_BYTES * 2:
raise IconError('image input too large')
data = json.loads(raw)
body, mime = _normalize(base64.b64decode(data['body'], validate=True), data['mime'])
else:
raise IconError('invalid worker arguments')
print(json.dumps({'mime': mime, 'body': base64.b64encode(body).decode()}))
except Exception:
sys.exit(1)