Skip to content
datahacks.dev
YAML

YAML Security Pitfalls and How to Test Your Own Code

Seven ways YAML hurts: object loading, alias bombs, size, type confusion, duplicate keys, leaked secrets and values used as code, tested in five languages.

17 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the YAML logo.

Most of the time YAML is a file you wrote, read by a program you trust. The security problems start when one of those assumptions fails: the file comes from someone else, the program does more than read it, or the file's history outlives its contents. YAML has more ways to go wrong than JSON because it is richer. It has tags that name types, aliases that share nodes, implicit types that vary by version, and a syntax permissive enough that two parsers can read one file two ways. This guide goes through seven pitfalls, each reproduced with a test we ran, with the defense that worked. It ends with an audit checklist and the cases where none of this is enough. If you need the language first, begin with YAML syntax you need to stop guessing.

Where YAML comes from

The first question is trust, and the answer changes by source. This table is a way to decide how strict to be.

SourceWho can change itTypical usePosture
Your repository, reviewedYour team, through pull requestsApp config, CI, manifestsSafe loader, schema, lint
A dependency or a vendored chartIts maintainersDefaults, templatesSafe loader, pin versions
A shared bucket or config serviceEveryone with write accessDynamic configSafe loader, schema, size caps
A user upload or an API bodyAnyoneImports, pipeline definitionsAll of the controls below
A pull request from a forkAny contributorCI that reads files from the changeTreat as untrusted input

The last row surprises people. A CI job that checks out a pull request and parses its YAML is parsing attacker-controlled data, and a parser that executes code or a workflow that interpolates a value into a shell makes the contributor a code author on your runner. The pitfalls below apply wherever the source is lower than "your reviewed repository".

Pitfall 1: the loader builds objects

A YAML tag names a type, and a loader that honors tags builds that type. The PyYAML unsafe loader will take !!python/object/apply:os.system and call the function with the arguments that follow. With a harmless payload that echoed text into /tmp/yaml_marker.txt:

Text
safe_load: ConstructorError could not determine a constructor for the tag 'tag:yaml.org,2002:python/object/apply:os.system'
marker after safe_load: False
full_load: ConstructorError could not determine a constructor for the tag 'tag:yaml.org,2002:python/object/apply:os.system'
marker after full_load: False
UnsafeLoader returned 0 | marker: code-ran

safe_load and full_load refused. UnsafeLoader returned 0, the exit status of the shell, and left the marker. No warning, no exception, a loaded value that looks like a number. The defense is the safe call: yaml.safe_load in PyYAML, YAML.safe_load (or the restricted YAML.load of Psych 4 and later) in Ruby, and a default SnakeYAML 2.x Yaml or a SafeConstructor in Java. Ruby's Psych 5.2.2 refused !ruby/object:Admin in both load and safe_load with DisallowedClass, and SnakeYAML 2.4 refused !!java.io.File with Global tag is not allowed. In PyYAML 6, calling yaml.load with no Loader argument is a TypeError. Older releases of all three were more permissive, and old tutorials still show the unsafe calls. The mechanism is in why loading untrusted YAML can run code, and the choice of function per language in safe loaders in Python, Ruby and Java.

Pitfall 2: aliases expand

An alias is a reference to an anchored node, and nesting them multiplies. A document where each level is a list of nine references to the one below grows by about 54 bytes per level and by a factor of nine in expanded size. We measured PyYAML:

Text
depth 5: yaml  270 bytes, safe_load    0.64 ms, json.dumps ->    428,103 bytes in      2.4 ms
depth 6: yaml  324 bytes, safe_load    0.78 ms, json.dumps ->  3,852,945 bytes in     22.6 ms
depth 7: yaml  378 bytes, safe_load    0.92 ms, json.dumps -> 34,676,523 bytes in    200.4 ms

safe_load took under a millisecond throughout, because PyYAML shares the node rather than copying. The cost arrives in whatever walks the result: serialization, a deep copy, a schema validator, a log line. A reader of the code sees a safe load and no problem until the first traversal. DuckDB's reader materializes values and paid the whole cost up front, about 51 seconds and 1 GB for the same depth, until yaml_set_max_expansion_nodes(1000) made it fail immediately with YAML expansion exceeded the maximum node budget (1000). Go's yaml.v3 refused a nine-level bomb with yaml: document contains excessive aliasing, SnakeYAML with Number of aliases for non-scalar nodes exceeds the specified max=50, and Ruby's safe loaders reject aliases unless asked.

Counting aliases is the wrong measure. The depth-seven bomb had 54 aliases, and an ordinary CI file with one anchor reused twice had 2. Cost depends on the fan-out product, so measure the expanded size, which you can do in PyYAML by composing to nodes and walking with memoization. The attack is in Billion Laughs style attacks through YAML aliases, and the controls per parser in limiting alias expansion in a YAML parser.

Pitfall 3: size and depth without aliases

An attacker does not need aliases to exhaust a process. A 70 KB scalar, a million small keys or two hundred nested brackets do it too. Nesting deserves a special note: deep structures can hit a language's recursion limit during loading or in your code afterward, turning into a crash. SnakeYAML's setNestingDepthLimit(10) rejected a 30-level list with Nesting Depth exceeded max 10, and our PyYAML guard stopped 200 levels with nesting deeper than 20. Cap the byte size before parsing, cap depth during it and cap the expanded size, in that order, because each check is cheaper than the next. Handling YAML uploads from users has the complete function.

Pitfall 4: type confusion

A YAML 1.1 parser reads no, off and n as booleans and 010 as octal. A YAML 1.2 parser does not. If a policy engine, a validator and the application each use a different parser, a value can mean different things to each of them, and an attacker who knows which is which can write a document that passes the check and behaves differently in use. We saw the shape of it in one six-line file: check-jsonschema read debug: yes as a string and rejected it for a boolean field, and the same schema applied to PyYAML's output accepted it, because PyYAML made it True.

The practical danger is subtler than a bypass. Python's truthiness makes the string "false" true and the string "no" true, so a flag written in quotes and read without a type check turns itself on. Validate types strictly: a schema with type: boolean or a Pydantic model with strict=True. Quote values that are meant as text. And validate with the loader the application uses, as in catching type mistakes in YAML early.

Pitfall 5: duplicate keys

The YAML specification says mapping keys must be unique, and parsers handle violations differently. A three-line file with role: user, admin: false and a second role: admin:

Text
PyYAML: {'role': 'admin', 'admin': False}
ruamel: DuplicateKeyError while constructing a mapping
yq: {"role":"user","admin":false,"role":"admin"}
dupe.yaml
  3:1       error    duplication of key "role" in mapping  (key-duplicates)

PyYAML kept the last value, and a downstream consumer reading the same document with a first-wins rule would see user. ruamel.yaml raised an error, yq printed both keys in its JSON, which is not valid input for a strict JSON consumer, and yamllint flagged line three. The security story is a parser differential: a check written against one tool's reading and an application that uses another's can disagree about the role. The remedy is to reject duplicates at the door, with yamllint's key-duplicates rule in CI and a loader that raises. The silent-overwrite behavior is covered in YAML duplicate keys and silent overwrites.

Pitfall 6: secrets in the files and in history

A token pasted into a values file and removed in the next commit is gone from the file and present in history. git log -p --all -S showed both the adding and the removing commit with the old line. A gitleaks scan of the two-commit repository reported leaks found: 1; a scan of the current files reported no leaks found. A scanner that checks only the checkout gives false comfort. The documented AWS example key was not flagged at all, because scanners allowlist well-known examples, so test a scanner with a random fake in the real format. The cleanup order is rotate, check use, replace with a reference, then rewrite history and purge caches, in that order, because rewriting cannot reach clones that exist. See secrets accidentally committed in YAML files and the prevention pattern in keeping secrets out of YAML config files.

Pitfall 7: values used as code

A safe loader returns plain data, and plain data can still be dangerous when your program acts on it. A string that becomes a path allows ../../. A string that becomes a command, a template or a query is an injection. CI is the sharpest example. A workflow that interpolates an issue title into a shell script, run: echo "New issue: ${{ github.event.issue.title }}", lets the title author run commands on the runner. actionlint flagged exactly that:

Text
.github/workflows/triage.yml:10:35: "github.event.issue.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable.

The corrected version passes the value through env: and reads $TITLE in the script; actionlint accepted it. A side lesson from writing that test: the first version put the run command on one line with : inside an unquoted plain scalar, and the file failed to parse (mapping values are not allowed in this context). Use a block scalar (run: |) for any command that contains a colon. The same rule, allow-list instead of interpolate, applies to Compose command: fields built from variables, Ansible shell: tasks and dbt {{ var() }} values that reach SQL. Generating SQL from YAML templates safely covers the SQL case.

The same bugs, again and again

None of these pitfalls is new, and the reason to learn them is that they recur. The object-loading class produced a famous run of Rails vulnerabilities in 2013, when YAML.load on request parameters let a remote attacker run code, and the lesson then was the one this guide repeats: a loader that constructs objects must not see untrusted input. The same class returned in Python as CVE-2020-14343 in PyYAML's full_load, which many people had adopted because it sounded safer than load, and in Java as the SnakeYAML issue tracked as CVE-2022-1471, which made the 2.0 release change its default to refuse global tags. Each time, the vulnerable call was common, the fix was a different function or a version bump, and the exposure lasted as long as the old code did.

The pattern is useful for planning. Parsers are fixed slowly and your dependency tree is old, so assume that some library you ship has a YAML loader with a past advisory, and assume the safe call is not the one used in the first tutorial a developer finds. Put the knowledge in a lint rule or a CI check, not in a person's memory. The grep patterns for finding the unsafe calls are in safe loaders in Python, Ruby and Java, and a scanner keeps the pattern from coming back.

One document, three readings

Type confusion and duplicate keys share a root: YAML does not define one reading of every document, and tools differ. In the earlier cluster on data tools, we loaded one block of six ambiguous values with four implementations. PyYAML, which dbt also uses, returned no as False, 010 as the octal 8 and 22:22 as the base-60 number 1342. ruamel.yaml returned 'no', the decimal 10 and the text '22:22'. yq kept the original spelling of the numbers. Docker Compose turned 3.10 into 3.1 and 010 into 8 before handing them to a container as strings.

For security, the point is not that one parser is right. It is that a control built on one reading can be bypassed by a document that another reading treats differently. A policy check that parses a manifest with a 1.2 tool and a deployment tool that parses it as 1.1 is a classic parser differential. Defend with three habits. Use one parser for the check and for the use, or pass the checked, re-serialized data forward instead of the original text. Quote values that carry meaning, so every reading agrees. And reject documents that are ambiguous, such as duplicate keys, rather than relying on every tool to resolve them the same way. The comparison across tools is in YAML in dbt, Airflow and other data tools.

CI that reads YAML from pull requests

Continuous integration is where untrusted YAML meets privileged execution, so it deserves its own paragraph. A workflow triggered by a pull request from a fork runs with limited permissions and no secrets on GitHub, by design. A workflow that runs on the base repository's context after a pull request event, such as pull_request_target, has more access, and GitHub's own guidance warns against checking out and running code or parsing files from the pull request there. If a privileged job must read the contributor's files, treat them as hostile input: parse them with the safe loader in an unprivileged step, validate them against a schema, pass only validated values onward, and never interpolate them into a script.

The interpolation rule is the one actionlint enforces. An expression such as ${{ github.event.issue.title }} is replaced with its text before the shell runs, so a title containing a quote and a command becomes part of the script. Passing the value through an environment variable, as the corrected workflow did, keeps it as data: the shell reads $TITLE and never parses it as code. Apply that to every expression that carries user-controlled text, including branch names, commit messages, labels and the body of an issue or comment. Run actionlint in CI so a new workflow is checked on the way in; writing GitHub Actions YAML that runs database tests shows the setup.

What to log and what to show

The handling of failures is part of the defense. An error message that echoes the rejected document can leak its contents into a log, into a response or into an alert. Some parser messages include a snippet of the input. Our guard cut each message to its first line, which carries the position and the category, and replaced the rest with fixed text such as not valid UTF-8 and keys must be strings. Two rules follow. Show the user the category and the position, which is enough to fix a file, and not the content. And log the rejection with the source and the size, and hash the body if you need to correlate repeated attempts, rather than storing it. A burst of rejections from one source is itself a signal worth alerting on.

Be careful with success paths too. Logging a loaded configuration at startup is common and prints secrets. If the config contains credentials, redact the fields by name before logging, and prefer loading credentials from a separate source so the main config is safe to print. A debugging line that prints the whole dictionary is how a token reaches a log aggregator that more people can read than the secrets store.

Where to start

A team with a lot of existing YAML code should not try to do everything at once. A sequence that fits in a few pull requests works.

  1. Find the unsafe loads. Grep for the patterns, list each call with its input source, and replace the ones that handle outside input with the safe function. This fixes the highest-severity bug class.

  2. Cap and bound the external inputs. Add the byte cap, expanded-size budget and depth limit in front of every parser that reads uploads or network data. One shared guard function is better than ten copies.

  3. Turn on the free checks. yamllint with key-duplicates in CI, a secret scanner on pull requests that covers commits, and actionlint on workflows. They cost minutes to add.

  4. Add schemas for the files that matter most, with strict types and additionalProperties: false.

  5. Write the attack tests from the corpus above and put them in CI.

  6. Review the downstream use of loaded values, which is the slowest and most valuable step, because it finds the injection paths that no scanner can.

Each step has a visible payoff, and the first one is a real vulnerability fix, so it is the one to do this week.

Why this matters for a data team

A data stack uses YAML in places that reach the database. Connection settings, dbt profiles, Compose files with passwords, Liquibase credentials and seed files all sit one step from production data. The attack surface therefore includes configuration that looks mundane. A database password written into a Compose environment block is a credential in a file that will be copied, committed and pasted into tickets. A dbt profiles.yml committed by mistake holds a warehouse login. A seed script that loads a YAML file from a shared folder runs as a role that can write tables, and a loader that builds objects turns that folder into a path to the database.

Three habits from this guide apply directly. Keep credentials out of files, with the file naming the environment variable that holds the secret, as in storing database connection config in YAML safely. Give the process that loads YAML the narrowest database role it needs, so a bug in the loader is a bug with a small blast radius, and give seed scripts their own role. And treat any YAML a data tool ingests from outside, such as a partner's manifest, a user's pipeline definition or a downloaded dataset description, as an upload with all the controls above. The database is the asset; YAML is the door people forget to lock.

Two more database-specific cautions. A YAML document stored in a column and later loaded by another service with an unsafe loader moves the exposure from your service to theirs, so store parsed jsonb and not raw text where you can (storing YAML text in a database column). And exports are copies of data: a YAML export of a table written to a shared folder carries whatever the table held, including personal data, so apply the same access controls as to the table itself.

Defense in depth

No single control covers all seven. The combination that has worked, in order of the request path:

  1. Cap bytes before reading.

  2. Decode strictly as UTF-8.

  3. Parse with the safe loader, composing first so you can measure.

  4. Limit expanded size and depth.

  5. Reject duplicate keys.

  6. Validate against a schema with additionalProperties: false and strict types (validating YAML before it breaks a deploy).

  7. Treat values as untrusted when they reach files, shells, templates and queries.

  8. Run the parse with limits: a timeout and a memory cap on the process.

  9. Keep secrets out of files and scan history.

A guard function implementing steps one to five and a type check accepted 2 of 10 test uploads, a good config and a document with a legitimate anchor, and rejected the rest: the Python tag, the bomb, 200 levels of nesting, an oversize file, a list at the top level, an integer key, bad UTF-8 and an unclosed bracket, each with a message safe to show.

Testing your own code

The way to know your service is safe is to attack it. A short test file carries the corpus:

  • A document with an object tag for your language. Assert that loading raises.

  • A depth-seven alias bomb. Assert that the request fails fast and the process memory stays flat.

  • A 10 MB file. Assert it is refused before parsing.

  • A document with 500 nested levels.

  • A duplicate-key document where the last value is the dangerous one.

  • A top-level list, a string, an integer and an empty file where a mapping is expected.

  • A key like __proto__ or constructor for JavaScript consumers, and a value like ../../etc/passwd where a path is used.

Put these in CI and add a case whenever someone finds a new bypass. Alongside the tests, run static checks: a grep for yaml.load(, UnsafeLoader, YAML.unsafe_load and new Yaml() in code, and a scanner such as Bandit, Brakeman or SpotBugs. The grep patterns are listed in the loaders post.

An audit checklist

For an existing system, work through these questions in order.

  1. Where does YAML enter the system, and who can write each source?

  2. Which function parses it, in which library version?

  3. Is there a byte cap before parsing?

  4. Are aliases allowed, and what bounds their expansion?

  5. Are duplicate keys rejected?

  6. Is the result validated against a strict schema?

  7. Where do loaded values go, and are any used as paths, commands, templates or SQL?

  8. Do any YAML files in the repository or its history contain credentials?

  9. Is CI reading YAML from pull requests, and does it interpolate any of it into scripts?

  10. Are parser dependencies on versions with known advisories?

The answers produce a short list of changes. In our experience of the examples above, the first three items fix the most severe findings and the rest tighten the tail.

When this guide's controls do not hold

The parser is not yours. A library that parses YAML inside another product, such as a CI service, a cloud API or an enterprise tool, has its own limits and its own advisories. You can validate before you send and nothing more.

The data is trusted and the process is not isolated. If a trusted file triggers a vulnerability in an old parser, the trust does not help. Keep parsers patched.

The risk is organizational. Secret scanning, rotation and least privilege decide how bad a leak is; a schema does not.

You need a format without these features. JSON has no tags, no aliases and one meaning per value. For machine-to-machine input from untrusted sources, it removes four of the seven pitfalls by construction. YAML versus JSON for humans and machines compares them.

You are relying on the editor. Squiggles in an IDE are not controls. Enforce in CI and at the service boundary.

References: OWASP's Deserialization Cheat Sheet explains the object-loading class, GitHub's guidance on script injection covers workflows, the YAML 1.2.2 specification defines aliases and key uniqueness, and the PyYAML documentation describes the loaders. Check a document's structure safely in the browser with the YAML formatter.

Also inSecurity
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.