File: //opt/cloudlinux/venv/lib/python3.11/site-packages/clcagefslib/webisolation/crontab/processor.py
# -*- coding: utf-8 -*-
#
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2025 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENCE.TXT
#
"""Processing functions for crontab operations."""
import os
import pwd
import shlex
import subprocess
import sys
from typing import BinaryIO, Callable, Optional
from clcagefslib.domain import is_isolation_enabled
from .constants import ISOLATION_WRAPPER
from .parser import (
parse_crontab_structure,
write_crontab_structure,
entries_to_str_list,
)
from .structure import EnvAssignmentLine, ParsedCrontabLine
from .utils import get_document_root
CRONTAB_BIN = "/usr/bin/crontab"
def _authenticate_caller_username() -> str:
"""Return the crontab owner's system name authoritatively.
F-10 (CLOS-5948) DiD: ``pwd.getpwuid(os.getuid()).pw_name`` returns
whichever passwd entry NSS iteration surfaces first for the caller's
UID. In same-UID CageFS sibling configurations (multiple accounts
sharing a numeric UID), that lookup can resolve to a *different*
sibling than the actual caller, letting the CRONTAB_* proxy path
read or replace another sibling's crontab.
The Python wrapper cannot recover the trusted socket-authenticated
name from proxyexec's cage.server dispatcher after ``setuid`` — the
only user-space signal that survives the crossing is
``PROXYEXEC_UID``, which is numeric and thus ambiguous in same-UID
configs. Since Vixie's ``crontab`` binary also derives the caller
name from ``getpwuid(getuid())`` (so ``crontab -u <name>`` cannot
disambiguate either), the safe stance is to REFUSE the operation
when the UID resolves to more than one system name.
Callers get a class-neutral ``RuntimeError`` to surface as a plain
error, never a Python traceback.
"""
uid = os.getuid()
matching_names = [
entry.pw_name for entry in pwd.getpwall() if entry.pw_uid == uid
]
if len(matching_names) != 1:
# Zero matches (no passwd entry for our own UID — unreachable
# in a healthy CageFS jail) OR more than one (same-UID sibling
# configuration — cannot determine the real caller from UID
# alone).
raise RuntimeError("Operation not permitted.")
return matching_names[0]
def process_list(stdout: Optional[BinaryIO] = None, stderr: Optional[BinaryIO] = None) -> int:
"""
Process CRONTAB_LIST command.
Runs 'crontab -l' to get current crontab entries. When isolation is active
(PROXYEXEC_DOCUMENT_ROOT is set), only shows entries for the current
document root. Removes isolation prefixes from output.
Args:
stdout: Output stream buffer (defaults to sys.stdout.buffer)
stderr: Error stream buffer (defaults to sys.stderr.buffer)
Returns:
int: Exit code from crontab command, or 1 on error
"""
stdout = stdout or sys.stdout.buffer
stderr = stderr or sys.stderr.buffer
username = _authenticate_caller_username()
# F-10 (CLOS-5948) DiD: Vixie/cronie rejects ``-u`` unless the real
# UID is root (``must be privileged to use -u``), and the
# CRONTAB_LIST proxy path runs after proxyexec ``setuid``s to the
# caller — passing ``-u`` here would break every legitimate list
# operation. The uniqueness check in ``_authenticate_caller_username``
# is the real defence: it refuses to invoke ``crontab`` at all when
# the caller's UID resolves to more than one passwd name, which is
# the same-UID sibling collision the finding described.
result = subprocess.run(
[CRONTAB_BIN, "-l"],
capture_output=True,
)
# early exit in case site isolation is not turned on
if not is_isolation_enabled(username):
stdout.write(result.stdout)
stderr.write(result.stderr)
return result.returncode
if result.returncode != 0:
# Pass through stderr from crontab
stderr.write(result.stderr)
return result.returncode
document_root = get_document_root()
# Parse structure and pick entries to show
structure = parse_crontab_structure(result.stdout)
if document_root is not None:
entries_to_show = structure.docroot_sections.get(document_root, [])
else:
entries_to_show = structure.global_records
# Convert selected entries to bytes, removing wrapper prefixes if isolation is active
result_parts = entries_to_str_list(entries_to_show, without_wrapper=bool(document_root))
output_data = b"".join(result_parts)
stdout.write(output_data)
return 0
# scanner-triage: docroot authz and local uid drop both close upstream.
# get_document_root (in .utils) authenticates the caller against
# userdomains(username), and PROXYEXEC_DOCUMENT_ROOT itself is set
# server-side from a validated .cagefs.website token — not from the
# tenant's env. The local uid drop is done by the proxyexec dispatcher:
# CRONTAB_* aliases carry `:secure:noproceed` (never `root:`), so
# setuid(pw_uid) always fires before execv and this code runs as the
# caller. Refile if any CRONTAB_* alias ever acquires a `root:` prefix,
# or if an admin-side caller imports process_save with EUID 0.
def process_save(
stdin: Optional[BinaryIO] = None,
stdout: Optional[BinaryIO] = None,
stderr: Optional[BinaryIO] = None,
run_func: Optional[Callable] = None,
) -> int:
"""
Process CRONTAB_SAVE command.
Reads crontab entries from stdin. If isolation is active:
1. Gets the current full crontab
2. Removes entries for the current document root
3. Adds new entries to the current document root section
4. Merges and saves the result in new format
If isolation is not active:
1. Gets the current full crontab
2. Replaces user records section with new entries
3. Preserves all docroot sections
4. Saves in new format
This ensures entries for other document roots are preserved.
Args:
stdin: Input stream buffer (defaults to sys.stdin.buffer)
stdout: Output stream buffer (defaults to sys.stdout.buffer)
stderr: Error stream buffer (defaults to sys.stderr.buffer)
run_func: Function to run subprocess (defaults to subprocess.run)
Returns:
int: Exit code from crontab command, or 1 on error
"""
stdin = stdin or sys.stdin.buffer
stdout = stdout or sys.stdout.buffer
stderr = stderr or sys.stderr.buffer
run_func = run_func or subprocess.run
# scanner-triage: this runs after the proxyexec dispatcher has entered
# the caller's LVE and setuid'd to their UID (CRONTAB_SAVE has no
# `nolve` flag), so every byte read is charged to the caller's own
# LVE — kernel SIGKILLs on overrun. Blowing up your own LVE is
# user-to-self, not a cross-tenant DoS.
input_data = stdin.read()
document_root = get_document_root()
if document_root is not None and ('\n' in document_root or '\r' in document_root):
raise ValueError(f'Invalid document root: {document_root!r}')
username = _authenticate_caller_username()
if is_isolation_enabled(username):
# Get current crontab to preserve entries
# F-10 (CLOS-5948) DiD: no ``-u`` — see the note in
# ``process_list`` above. The uniqueness check in
# ``_authenticate_caller_username`` is the real defence.
list_result = run_func(
[CRONTAB_BIN, "-l"],
capture_output=True,
)
# No existing crontab or error - start fresh
# Note: returncode 1 typically means "no crontab for user", which is acceptable
existing_data = list_result.stdout if list_result.returncode == 0 else b""
# Parse existing crontab into structure
existing_structure = parse_crontab_structure(existing_data)
# Parse new input (filtered data, no section markers)
# input_data is already bytes from stdin.read()
new_structure = parse_crontab_structure(input_data)
# Extract entries and add wrapper prefixes for docroot sections
parsed_entries = []
for entry in new_structure.global_records:
# Drop env-assignment lines (`SHELL=`, `PATH=`, `HOME=`, `MAILTO=`,
# ...) from per-site sections — crond honours them in textual
# order and would apply them to following job lines, running an
# attacker-controlled SHELL before the isolation wrapper and
# bypassing per-docroot CageFS scope. Env assignments are only
# safe in the user's global section, never inside an isolated
# docroot block. F-09 (CLOS-5947): the classifier now also
# recognises the `"NAME"=` and `'NAME'=` quoted forms that
# vixie-cron's load_env accepts, so those forms are dropped
# here too.
if document_root and isinstance(entry, EnvAssignmentLine):
continue
if isinstance(entry, ParsedCrontabLine) and document_root:
# Decode command to string for shlex.quote()
command_bytes = entry.command
has_newline = command_bytes.endswith(b"\n")
command_str = command_bytes.rstrip(b"\n").decode("utf-8", errors="replace")
# Use shlex.quote() to properly quote the command for bash -c
quoted_command = shlex.quote(command_str)
# Build the wrapped command: wrapper path docroot bash -c "quoted_command"
# Use shlex.join() for the prefix to handle paths with spaces, then add quoted command
prefix_parts = shlex.join([ISOLATION_WRAPPER, document_root, "bash", "-c"])
wrapped_command_str = f"{prefix_parts} {quoted_command}"
wrapped_command = wrapped_command_str.encode("utf-8")
if has_newline:
wrapped_command += b"\n"
parsed_entries.append(
ParsedCrontabLine(schedule=entry.schedule, command=wrapped_command)
)
else:
# Keep as-is for comments and when document_root is None (may have wrapper prefixes)
parsed_entries.append(entry)
if document_root:
# Isolation active: replace docroot section with new entries
existing_structure.docroot_sections[document_root] = parsed_entries
else:
existing_structure.global_records = parsed_entries
# Write in new format
modified_data = write_crontab_structure(existing_structure)
else:
# pass through
modified_data = input_data
# F-10 (CLOS-5948) DiD: no ``-u`` here either — Vixie/cronie rejects
# ``-u`` under a setuid'd caller. The uniqueness check earlier in
# this function (via ``_authenticate_caller_username``) is what
# closes the same-UID sibling collision.
result = run_func(
[CRONTAB_BIN, "-"],
input=modified_data,
capture_output=True,
)
# Pass through any output from crontab
if result.stdout:
stdout.write(result.stdout)
if result.stderr:
stderr.write(result.stderr)
return result.returncode