Skip to content

Remote hook manifests can write to arbitrary host paths via log_file #3655

@eddieran

Description

@eddieran

Summary

pre-commit accepts the log_file field from remote hook manifests (.pre-commit-hooks.yaml) and later opens that path directly on the host filesystem in append mode when a hook produces output. Because the path is neither constrained to the repository root nor stripped for non-local manifests, a malicious hook repository can append attacker-controlled content to arbitrary relative or absolute host paths.

This is a tool-level trust-boundary issue — not simply "hooks execute code by design." The validated proof of concept uses language: fail, so no hook code from the remote repository is executed. The file write comes purely from manifest metadata.

Severity: Medium

Affected Code

Manifest schema accepts log_file for remote repos

pre_commit/clientlib.py, line 261:

MANIFEST_HOOK_DICT = cfgv.Map(
    'Hook', 'id',
    # ...
    cfgv.Optional('log_file', cfgv.check_string, ''),
    # ...
)

log_file is permitted in MANIFEST_HOOK_DICT (the schema used for .pre-commit-hooks.yaml in remote repos), with no path validation.

Hook output is written to the manifest-controlled path

pre_commit/commands/run.py, line 232:

if out.strip():
    output.write_line()
    output.write_line_b(out.strip(), logfile_name=hook.log_file)
    output.write_line()

The path is opened directly in append mode

pre_commit/output.py, lines 14–29:

def write_line_b(
        s: bytes | None = None,
        stream: IO[bytes] = sys.stdout.buffer,
        logfile_name: str | None = None,
) -> None:
    with contextlib.ExitStack() as exit_stack:
        output_streams = [stream]
        if logfile_name:
            stream = exit_stack.enter_context(open(logfile_name, 'ab'))
            output_streams.append(stream)
        # ...

No validation is performed on logfile_name — it is opened as-is with open(logfile_name, 'ab').

Steps to Reproduce

  1. Create a malicious hook repository with .pre-commit-hooks.yaml:
- id: evil-hook
  name: evil hook
  entry: |
    attacker-controlled content here
    this gets written to the target file
  language: fail
  files: ''
  always_run: true
  log_file: ../escape-to-parent.txt
  1. Create a victim repository that uses this hook in .pre-commit-config.yaml:
repos:
  - repo: https://github.com/<attacker>/malicious-hooks
    rev: v1.0.0
    hooks:
      - id: evil-hook
  1. Run pre-commit run --all-files in the victim repository.

  2. Observe that ../escape-to-parent.txt is created one directory above the victim repo, containing the attacker-controlled entry string.

Validation

I validated this in an isolated Docker environment:

  • Created a local "malicious" hook repo with log_file: ../manifest-log-file-write.txt and language: fail
  • Ran pre-commit from a separate victim repo directory
  • Confirmed that manifest-log-file-write.txt was created one directory above the victim repo
  • The file content was the attacker-controlled text from the hook's entry field

Impact

A malicious hook repository can:

  • Write outside the repository using relative traversal (../target) or absolute paths
  • Create new files on the host filesystem
  • Append attacker-controlled bytes to existing files
  • The content is controlled through the manifest entry field, so the attacker has full control over what is written

This is achieved without any hook code execution — language: fail hooks never run an interpreter. The write primitive comes purely from manifest metadata being trusted as a file path.

Suggested Fix

The least disruptive fix would be to make log_file config-only, i.e., only honor it from the user's .pre-commit-config.yaml and ignore it from remote manifests (.pre-commit-hooks.yaml).

Alternatively:

  1. Remove log_file from MANIFEST_HOOK_DICT entirely (only keep it in the config schema).
  2. If manifest support must remain, normalize the path against an allowed base directory and reject:
    • Absolute paths
    • .. traversal components
    • Symlink escapes outside the repository root

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions