# RCA Report — CVE-2026-63720

## Summary

datamodel-code-generator (koxudaxi) before 0.70.0 contains a code-injection
vulnerability in its JSON Schema `customBasePath` extension handling. The
generator emits the schema-controlled `customBasePath` value verbatim into the
generated module's `from <module> import <name>` statement (and the model
base-class list) via `Import.from_full_path`, which splits the value on `.` and
performs no identifier validation. A malicious schema whose `customBasePath`
embeds a newline plus a dot-free Python expression causes attacker-controlled
Python statements to execute when a victim imports the generated module. The
vulnerability is reachable through the user-facing `datamodel-codegen` CLI
generator input-to-output path.

## Impact

- **Package/component affected:** `datamodel_code_generator` parser
  (`src/datamodel_code_generator/parser/jsonschema.py`,
  `JsonSchemaObject.custom_base_path` /
  `Import.from_full_path` /
  `Parser._resolve_base_class`).
- **Affected versions:** datamodel-code-generator before 0.70.0; vulnerable
  checkout anchored at `af435d9893d1352924a2f011a2eb04a997d8b978` (parent of the
  fix, reported as `0.69.0`).
- **Risk level:** High. An attacker who supplies a JSON Schema to a victim
  running the generator achieves arbitrary Python code execution in the
  victim's import context when the generated module is imported. The
  generator output is the natural consumption path of the CLI, so the
  weaponized module is a direct product artifact.

## Impact Parity

- **Disclosed/claimed maximum impact:** Arbitrary code execution (code
  injection on import of generated output).
- **Reproduced impact from this run:** Arbitrary attacker-controlled Python
  code execution. Two fresh vulnerable `datamodel-codegen` process instances
  generated modules whose import executed attacker-controlled `print(...)`
  writing distinct marker files (`DMCG_RCE_INST1`, `DMCG_RCE_INST2`). The
  fixed build rejected the same schema with exit code 2 and produced no
  output and no marker.
- **Parity:** `full`.
- **Not demonstrated:** None. The requested code-execution impact was
  demonstrated directly through the CLI entrypoint and the generated-module
  import consumption path.

## Root Cause

`JsonSchemaObject` models the JSON Schema `customBasePath` extension as
`custom_base_path: str | list[str] | None` (alias `customBasePath`) with no
validation. The parser resolves a model's base class via
`Parser._resolve_base_class(class_name, obj.custom_base_path)`, which returns
the raw `custom_base_path` string. That string is fed to
`Import.from_full_path`, which splits the value on `.` to derive a module path
and an import name, and the result is emitted verbatim into the generated
`from <module> import <name>` statement and the model base-class list. Because
the value is never checked to be a dotted Python identifier path, a value
containing a newline plus attacker-controlled Python text is written into the
generated module. Importing that module executes the injected text at module
top level.

A leading comma in the payload (`pydantic.BaseModel,\n<expr>`) is used so that
both the generated `from pydantic import BaseModel,\n<expr>` statement and the
`class ExploitModel(BaseModel,\n<expr>):` definition stay syntactically valid
(the newline is a continuation inside the comma-separated lists), letting the
module compile and the injected `<expr>` execute on import.

The fix commit `545a96c56d1b6a8dd3f4a16c9090d8a4648d1e43` ("Merge commit from
fork") adds a `field_validator("custom_base_path", mode="before")` that calls
`_validate_schema_python_import_path` (which delegates to
`_validate_dotted_python_identifier_path`) on every scalar/list member. Values
that are not dotted Python identifier paths raise `Error: customBasePath must
be a dotted Python identifier path: ...`, the CLI exits with code 2, and no
output file is written.

## Reproduction Steps

1. Script: `bundle/repro/reproduction_steps.sh`.
2. What the script does:
   - Reads `bundle/project_cache_context.json` and reuses the prepared repo at
     `/pruva/project-cache/repo` when `prepared=true`; otherwise clones the
     repository.
   - Resolves the vulnerable commit `af435d98...` (parent of fix) and the fixed
     commit `545a96c5...`, and verifies the `validate_custom_base_path`
     validator is absent in the vulnerable checkout and present in the fixed
     checkout.
   - Creates two isolated git worktrees (one per commit) and editable-installs
     each into its own venv so the two builds cannot mutate each other's source
     tree.
   - Generates a malicious JSON Schema whose `customBasePath` is
     `pydantic.BaseModel,\nprint('<MARKER>', file=open('<path>','w'))` (the
     injected expression is dot-free so it survives `Import.from_full_path`'s
     split-on-`.`).
   - Runs the real `datamodel-codegen` CLI (`--input ... --input-file-type
     jsonschema --output ...`) on the schema for two fresh vulnerable process
     instances, then imports each generated module; the injected `print()`
     executes at module top level and writes a distinct marker file.
   - Runs the same CLI against the fixed build; it rejects the schema with
     exit code 2, writes no output, and produces no marker (negative control).
   - Writes `bundle/repro/runtime_manifest.json` and
     `bundle/repro/negative_control_observation.json`.
3. Expected evidence of reproduction: marker files `repro/marker_inst1`
   (`DMCG_RCE_INST1`) and `repro/marker_inst2` (`DMCG_RCE_INST2`) written by
   the vulnerable imports; `repro/generated_vuln_inst1.py` showing the injected
   `print(...)` at module level; fixed codegen log showing the rejection;
   `negative_control_observation.json` showing no marker and exit code 2.

## Evidence

- `bundle/logs/reproduction_steps.log` — full run transcript (verdict
  CONFIRMED).
- `bundle/logs/vuln_codegen_inst1.log`,
  `bundle/logs/vuln_codegen_inst2.log` — vulnerable CLI codegen output (exit
  0).
- `bundle/logs/vuln_import_inst1.log`,
  `bundle/logs/vuln_import_inst2.log` — generated-module import output
  (executes injected print, then TypeError from the None base class).
- `bundle/logs/fixed_codegen.log` — fixed CLI rejection (exit 2,
  `customBasePath must be a dotted Python identifier path`).
- `bundle/repro/malicious_schema_inst1.json`,
  `bundle/repro/malicious_schema_inst2.json`,
  `bundle/repro/malicious_schema_fixed.json` — attacker-controlled schemas.
- `bundle/repro/generated_vuln_inst1.py` — generated module containing the
  injected `print('DMCG_RCE_INST1', file=open(...,'w'))` at line 9.
- `bundle/repro/marker_inst1` = `DMCG_RCE_INST1`,
  `bundle/repro/marker_inst2` = `DMCG_RCE_INST2` — proof of executed
  attacker-controlled code.
- `bundle/repro/negative_control_observation.json` — fixed build: exit 2, no
  output, no marker.
- `bundle/repro/runtime_manifest.json` — runtime evidence manifest.

Key excerpt from `generated_vuln_inst1.py`:

```python
from __future__ import annotations

from pydantic import BaseModel

print('DMCG_RCE_INST1', file=open('/workspace/bundle/repro/run/marker_inst1', 'w'))


class ExploitModel(
    BaseModel,
    print('DMCG_RCE_INST1', file=open('/workspace/bundle/repro/run/marker_inst1', 'w')),
):
    value: str | None = None
```

Environment: CPython 3.14 on Linux x86_64; datamodel-code-generator 0.69.0
(vulnerable) and 0.69.1.dev1+g545a96c56 (fixed); pydantic v2.

## Recommendations / Next Steps

- **Upgrade guidance:** Upgrade datamodel-code-generator to 0.70.0 or later,
  which contains the `customBasePath` field_validator.
- **Suggested fix approach:** The applied fix is appropriate: validate every
  scalar and list member of `customBasePath` (and the analogous
  `customTypePath` / `x-python-import` paths) with
  `_validate_dotted_python_identifier_path` before any code generation, and
  reject non-identifier values with a clear error and no output. Consider also
  hardening the generator's import/base-class emission to escape or reject
  control characters (newlines) in schema-controlled identifier fields as
  defense in depth.
- **Testing recommendations:** Add regression tests that feed schemas with
  newline-bearing and non-identifier `customBasePath`/`customTypePath` values
  and assert the CLI exits non-zero with no output; the fix commit already
  adds `unsafe_custom_base_path_scalar.json` /
  `unsafe_custom_base_path_list_nested.json` fixtures for this.

## Additional Notes

- **Idempotency confirmation:** The script was run twice consecutively; both
  runs exited 0 with `CONFIRMED=1`, both vulnerable instances wrote their
  distinct markers, and the fixed build rejected the schema both times. The
  script recreates worktrees/venvs each run, so it is self-cleaning.
- **Why the comma is required:** Without the leading comma in the payload,
  `class ExploitModel(BaseModel\n<expr>):` becomes a `SyntaxError` (two
  expressions with no separator inside the base-class list), which prevents
  the whole module from compiling and blocks execution of the injected
  statement. The comma keeps both the import and the class definition
  syntactically valid so the module compiles and the injected statement
  executes at import time; the subsequent `TypeError` (metaclass conflict
  from the `None` returned by `print`) occurs after the marker is written.
- **Why the expression must be dot-free:** `Import.from_full_path` splits the
  raw `customBasePath` string on `.`, so any `.` in the injected text
  (including dots inside string literals) breaks the payload across segments
  and the final segment no longer contains the intended expression. The
  `print(..., file=open(...))` form is dot-free.
- **Surface match:** The claim surface is `cli_local` /
  `cli_command`. The reproduction exercises the real `datamodel-codegen` CLI
  entrypoint (`scripts.datamodel-codegen = "datamodel_code_generator.__main__:main"`)
  end-to-end and demonstrates the claimed code-execution impact through the
  generated-module import consumption path.
