diff --git a/news/525.bugfix.md b/news/525.bugfix.md new file mode 100644 index 00000000..d58a6572 --- /dev/null +++ b/news/525.bugfix.md @@ -0,0 +1,8 @@ +Files written by `to_html(path)`, `to_svg(path)`, `write_image`, `write_images` +and facet exports now get the same permissions as any other new file (normally +`rw-r--r--`), and an existing POSIX file keeps its ordinary permission bits +and group when possible. +Linux replacements also retain the existing access ACL; failed permission +adjustments leave the export private. Exports were previously created owner-only (`rw-------`), so a web server or another user could not read them. + +Filesystems without ACL support also retain ordinary permission bits on replacement. diff --git a/python/xy/export.py b/python/xy/export.py index f2f3dec1..c8e5d105 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -4,13 +4,16 @@ from __future__ import annotations import base64 +import errno import html as _html import json import math import numbers import os import re as _re +import secrets import shutil +import stat import subprocess import sys import tempfile @@ -216,6 +219,72 @@ def _base64_chunks(blob: bytes) -> list[str]: ) +def _new_file_mode(target: Path) -> int: + """Read new-file permissions without changing the process-wide umask.""" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + for _ in range(tempfile.TMP_MAX): + probe = target.parent / f".{target.name}.{secrets.token_hex(4)}.mode" + try: + fd = os.open(probe, flags, 0o666) + except FileExistsError: + continue + try: + return stat.S_IMODE(os.fstat(fd).st_mode) + finally: + os.close(fd) + with suppress(FileNotFoundError): + probe.unlink() + raise FileExistsError(f"no free temporary file name beside {str(target)!r}") + + +def _preserve_access_acl(fd: int, target: Path) -> None: + """Keep a Linux replacement from gaining the directory's inherited ACL.""" + if not sys.platform.startswith("linux"): + return + attribute = "system.posix_acl_access" + absent_acl_errors = (errno.ENODATA, errno.ENOTSUP, errno.EOPNOTSUPP) + try: + acl = os.getxattr(target, attribute) + except OSError as error: + if error.errno not in absent_acl_errors: + raise + try: + os.removexattr(fd, attribute) + except OSError as error: + if error.errno not in absent_acl_errors: + raise + else: + os.setxattr(fd, attribute, acl) + + +def _publish_mode(fd: int, target: Path) -> None: + """Give the finished temp file the mode `open()` would have given the export. + + `tempfile.mkstemp` creates `0o600` so nothing can read the file while it is + being written, but `os.replace` then carries that mode onto the target and + every export ended up owner-only. The final mode is applied through the + descriptor, after the data is flushed and immediately before the replace. + Best effort: if the mode cannot be determined or applied (no free inode for + the probe, a filesystem without chmod, or an unpreservable group/ACL), + the export is still published, just owner-only as before. + """ + if os.name != "posix": # Windows fchmod changes the read-only attribute. + return + with suppress(OSError): + try: + original = target.stat() + except FileNotFoundError: + mode = _new_file_mode(target) + else: + # Preserve the access principals before enabling their bits. A + # failed group/ACL operation leaves the mkstemp file private. + if os.fstat(fd).st_gid != original.st_gid: + os.fchown(fd, -1, original.st_gid) + _preserve_access_acl(fd, target) + mode = stat.S_IMODE(original.st_mode) & 0o777 + os.fchmod(fd, mode) + + def _atomic_write_bytes(path: str | PathLike[str], data: bytes) -> None: """Write bytes through a same-directory temp file, then replace atomically.""" target = Path(path) @@ -227,6 +296,7 @@ def _atomic_write_bytes(path: str | PathLike[str], data: bytes) -> None: f.write(data) f.flush() os.fsync(f.fileno()) + _publish_mode(f.fileno(), target) os.replace(tmp_path, target) except Exception: if fd != -1: @@ -254,6 +324,7 @@ def _atomic_write_text(path: str | PathLike[str], text: str) -> None: f.write(text) f.flush() os.fsync(f.fileno()) + _publish_mode(f.fileno(), target) os.replace(tmp_path, target) except Exception: if fd != -1: diff --git a/spec/api/export.md b/spec/api/export.md index 259e403d..4d37d219 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -51,6 +51,9 @@ configured name with no entry). With no `export` spec the menu falls back to There is no Reflex-side export API. `python/reflex_xy/` renders without a kernel in the browser; export runs on the composed chart object in Python. +All path writers, including HTML/SVG and facet exports, use the atomic-write +and permission contract in [§8](#8-batch-export). + ## 2. Formats and backends `IMAGE_FORMATS = ("png", "jpeg", "webp", "svg", "pdf")`, with `jpg` aliased to @@ -228,9 +231,20 @@ Two properties are the point of the API: browser at all. Writes are atomic per file (same-directory temp file, fsync, `os.replace`), so a -reader never observes a partial image. Failure mid-batch is not transactional: -files already written stay on disk. The return value is the list of written -byte strings, in input order. +reader never observes a partial image. The temp file stays owner-only while it +is written; just before the replace it takes the mode a plain `open(path, "w")` +would leave (an existing file's permission bits, or for a new file `0o666` +filtered by the umask or the directory's default ACL), so exports are not left +owner-only. POSIX replacements preserve the existing group before enabling its +permission bits; on Linux they also retain the existing access ACL instead of +gaining the directory's inherited named-user/group entries. Missing ACLs and +unsupported ACL operations (`ENODATA`, `ENOTSUP`/`EOPNOTSUPP`) count as no ACL; +these filesystems still receive their ordinary permission bits. If determining or +applying the group, ACL, or mode fails, the export is still published with its +private temporary-file permissions. Special setuid/setgid/sticky bits are not +restored. Windows retains the previous atomic-write behavior without applying +POSIX modes. Failure mid-batch is not transactional: files already written stay +on disk. The return value is the list of written byte strings, in input order. ## 9. What styling survives which export path XY has five styling mechanisms (`spec/api/styling.md` § The five ways to style) diff --git a/tests/test_export_permissions.py b/tests/test_export_permissions.py new file mode 100644 index 00000000..99568dc9 --- /dev/null +++ b/tests/test_export_permissions.py @@ -0,0 +1,150 @@ +"""Atomic export permissions must not widen access across platforms or groups.""" + +from __future__ import annotations + +import errno +import os +import stat +import struct +import sys +from types import SimpleNamespace + +import pytest + +from xy import export as export_module + + +def test_publish_mode_skips_non_posix_even_when_fchmod_exists(tmp_path, monkeypatch): + target = tmp_path / "chart.html" + target.write_text("original") + + def reject(*args): + pytest.fail("non-POSIX export attempted to set POSIX permissions") + + simulated_windows = SimpleNamespace(**{**vars(os), "name": "nt", "fchmod": reject}) + monkeypatch.setattr(export_module, "os", simulated_windows) + export_module._publish_mode(-1, target) + assert target.read_text() == "original" + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX group ownership") +@pytest.mark.parametrize("writer", ["bytes", "text"]) +@pytest.mark.parametrize("deny_group_change", [False, True]) +def test_existing_export_preserves_group_or_stays_private( + tmp_path, monkeypatch, writer, deny_group_change +): + target = tmp_path / "chart.html" + target.write_text("old") + original_group = target.stat().st_gid + other_groups = set(os.getgroups()) - {original_group} + if not other_groups: + pytest.skip("requires membership in a second group") + restricted_group = min(other_groups) + os.chown(target, -1, restricted_group) + target.chmod(0o640) + if deny_group_change: + + def reject_group_change(fd, uid, gid): + assert uid == -1 and gid == restricted_group + raise OSError(errno.EPERM, "cannot preserve group") + + monkeypatch.setattr(export_module.os, "fchown", reject_group_change) + if writer == "bytes": + export_module._atomic_write_bytes(target, b"new chart") + else: + export_module._atomic_write_text(target, "new chart") + assert target.read_bytes() == b"new chart" + published = target.stat() + if deny_group_change: + assert stat.S_IMODE(published.st_mode) == 0o600 + else: + assert published.st_gid == restricted_group + assert stat.S_IMODE(published.st_mode) == 0o640 + assert not list(tmp_path.glob(".*.tmp")) + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux POSIX ACL xattrs") +@pytest.mark.parametrize("existing_acl", [False, True]) +def test_replacement_does_not_inherit_new_named_user_access(tmp_path, existing_acl): + def acl_for(user): + entries = [ + (1, 6, 0xFFFFFFFF), + (2, 4, user), + (4, 0, 0xFFFFFFFF), + (16, 4, 0xFFFFFFFF), + (32, 0, 0xFFFFFFFF), + ] + return struct.pack(" None: assert not list(tmp_path.glob(".chart.html.*.tmp")) +@pytest.mark.skipif(os.name != "posix", reason="POSIX permission bits") +@pytest.mark.parametrize("name", ["chart.html", "chart.png", "chart.svg", "chart.pdf", "batch.png"]) +def test_path_exports_get_the_permissions_open_would_give( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, name: str +): + """The atomic temp file must not leak its owner-only mode onto the export: + a new file gets the mode `open(path, "w")` gives beside it, and an existing + file keeps its own mode. The temp file stays private until it is complete.""" + fig = Figure(title="permissions").line([0.0, 1.0], [1.0, 2.0]) + + def export(target: Path) -> None: + if name.startswith("batch"): + export_module.write_images([fig], [target]) + elif target.suffix == ".html": + fig.to_html(target) + elif target.suffix == ".svg": + fig.to_svg(target) + else: + fig.write_image(target) + + written_modes: list[int] = [] + real_fsync = export_module.os.fsync + + def recording_fsync(fd: int) -> None: + written_modes.append(stat.S_IMODE(os.fstat(fd).st_mode)) + real_fsync(fd) + + monkeypatch.setattr(export_module.os, "fsync", recording_fsync) + previous = os.umask(0o022) + try: + reference = tmp_path / "reference" + reference.open("w").close() + expected = stat.S_IMODE(reference.stat().st_mode) + + created = tmp_path / name + export(created) + assert stat.S_IMODE(created.stat().st_mode) == expected + + existing = tmp_path / f"shared-{name}" + existing.write_bytes(b"old") + existing.chmod(0o664) + export(existing) + assert stat.S_IMODE(existing.stat().st_mode) == 0o664 + assert existing.read_bytes() != b"old" + finally: + os.umask(previous) + assert written_modes and all(mode == 0o600 for mode in written_modes) + assert not list(tmp_path.glob(".*.tmp")) and not list(tmp_path.glob(".*.mode")) + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX permission bits") +@pytest.mark.parametrize("failure", ["probe", "fchmod"]) +def test_export_still_lands_when_the_mode_cannot_be_applied( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: str +): + """Applying the open()-style mode is best effort: a directory that cannot + take one more file (no free inode), or a filesystem without chmod, still + gets the finished export, owner-only as before.""" + fig = Figure(title="probe").line([0.0, 1.0], [1.0, 2.0]) + if failure == "probe": + real_open = export_module.os.open + + def no_probe(path, flags, mode=0o777, *args, **kwargs): + if str(path).endswith(".mode"): + raise OSError(errno.ENOSPC, os.strerror(errno.ENOSPC)) + return real_open(path, flags, mode, *args, **kwargs) + + monkeypatch.setattr(export_module.os, "open", no_probe) + else: + + def no_fchmod(fd, mode): + raise OSError(errno.EPERM, os.strerror(errno.EPERM)) + + monkeypatch.setattr(export_module.os, "fchmod", no_fchmod) + target = tmp_path / "chart.html" + html = fig.to_html(target) + assert target.read_text(encoding="utf-8") == html + assert stat.S_IMODE(target.stat().st_mode) == 0o600 + assert not list(tmp_path.glob(".*.tmp")) and not list(tmp_path.glob(".*.mode")) + + def test_figure_dom_slots_are_validated_before_export(): fig = Figure().line([0.0, 1.0], [1.0, 2.0]) fig.class_names = {"legend": "ok", "legnd": "typo"}