TRENDING
An empty toll plaza entry with several lanes under a steel canopy, green arrow signals above the booths and a toll fare board beside the road
November 6, 2026
How to Spread Requests Across Servers in Python With the Power of Two Choices and Avoid the Herd Effect of Least-Loaded Routing
Cross-shaped arrow slit set in a recessed brick arch in a lichen-spotted castle wall at White Castle, Monmouthshire
November 6, 2026
Microsoft’s PDD Kit Turns Code Review Into Intent Review, but Verus Accepts a Login That Answers Every Attempt With a 500 Under the Contract the Post Prints
Several dried pufferfish with their spines raised hanging in a stack at a seaside market stall
November 6, 2026
OpenSSH Patches a Password Bypass on musl Systems Such as Alpine, Where a 257 to 1,024 Byte Password Logs In to Password-Disabled Accounts, and musl’s Maintainer Says the Bug Is Not in musl
Black silhouette of a wooden hourglass on a ledge against a grey cloudy sky with blurred bare branches
November 6, 2026
How to Stop Hung HTTP Calls in Python With Timeouts and Deadlines, and Catch the Slow-Drip Response That Slips Under requests, httpx, and urllib
Woodland path closed off with red and white barrier tape, traffic cones and a warning triangle
November 6, 2026
OpenAI’s “A Thousand Fewer Paths to Root” Turns $1.2 Million of API Credit Into 948 Candidate Patches, but Mainline Linux Shows 24 OpenAI-Linked Commits Since Mid-September and the Bounty Math Assumes Payouts the Rules Do Not Offer
06 Nov 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A chrome ticket punch lying on a bright blue surface, the tool that makes a ticket good for one use only
How to Secure a WebSocket Endpoint in FastAPI and Stop Cross-Site Hijacking, Leaked Tokens, Floods, and Slow Readers
November 6, 2026
Yellow stenciled words Mind the gap painted on a station platform edge beside the railway tracks
OpenAI’s Astra Report Turns Silent Safety-Task Underperformance Into a Matched-Pair Gap, but the Ethical-Doubt Result’s Bar Runs From a 4-Point Gap to a 3.6-Point Reversal, and the Launch Card Quoted AISI as Seeing Very Few Refusals
November 6, 2026
Black and white photo of a speckled stoneware honey pot with a lid and a wooden dipper standing in its opening, against a dark background
OpenAI Releases GPT-6 Cyber, Rated Critical, to Approved Customers: the Post’s 99.93% Scope Score Is Measured Against the Older Cyber Model, the System Card Puts Its Base Model at 0% Honeypot Exploitation, and One Outside Benchmark Has It Solving Fewer Challenges
November 6, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 285 Posts
News 289 Posts
Learning Hub 259 Posts
Home/Learning Hub/How to Load Untrusted YAML Safely in Python and Stop Code Execution, Alias Bombs, Duplicate Keys, and the Norway Problem
Learning Hub

How to Load Untrusted YAML Safely in Python and Stop Code Execution, Alias Bombs, Duplicate Keys, and the Norway Problem

Watch yaml.load run a function from a file, see why safe_load is only the first fix, then build a strict PyYAML loader that refuses alias bombs, duplicate keys and the Norway problem, with 49 tests...

November 2, 2026 35 Min Read
27

A YAML file looks like harmless data: a few names, a few numbers, a list. Then someone loads it with the wrong function and discovers that the file can also be a program. YAML sits behind CI pipelines, Kubernetes manifests and countless application settings, and it often comes from people you do not control: a plugin author, a pull request, a template someone downloaded. In Python the library that reads it is usually PyYAML, and its documentation is blunt: “yaml.load is as powerful as pickle.load and so may call any Python function.”

Table Of Content

  • The words you need first
  • What you will build, and what it does not do
  • Prerequisites
  • Step 1: Set up the lab and check your versions
  • Step 2: Watch a YAML file call a Python function
  • Step 3: Find out which loaders are dangerous
  • Step 4: Switch to safe_load and see what it refuses
  • Step 5: Meet aliases, and see why a small file can describe a huge one
  • Step 6: Catch the key that appears twice
  • Step 7: See what safe_load guesses when you do not use quotes
  • Step 8: Build a loader that only returns plain data
  • 8a. Limits and one error type
  • 8b. Check the whole document before building any of it
  • 8c. The loader class
  • 8d. Replace the type guesses
  • 8e. The entry points
  • 8f. Try it on every nasty document
  • 8g. Compare three loaders side by side
  • Step 9: Check the shape of the data with a schema
  • Step 10: Test every promise, then break the loader on purpose
  • Step 11: Stop the next unsafe call before it merges
  • Step 12: Confirm the whole thing works end to end
  • Common mistakes and gotchas
  • Where to go next

In this tutorial you will see that for yourself with a harmless test, learn why yaml.safe_load is the right first fix, and then learn what it still lets through: a 1,914-byte file that describes more data than any computer can hold, a key that appears twice so that the last one silently wins, and plain words such as NO that quietly turn into booleans. You will finish with a small loader, a schema check, 49 tests and a CI gate. This is the YAML chapter of a series on loading data you do not trust, after our tutorials on pickle, XML and decompression bombs. It also pairs with our tutorial on kubectl’s KYAML output and YAML’s Norway bug, which explains the same type-guessing trap from the Kubernetes side.

The words you need first

A loader is the PyYAML class that turns YAML text into Python objects; yaml.safe_load(text) is the same as loading with yaml.SafeLoader. A tag is a label on a value that says what type it is, such as !!str. PyYAML also understands tags that name Python things, for example !!python/object/apply:os.mkdir, and that is where the danger starts. An anchor (&name) marks a value and an alias (*name) reuses it, so you can write something once and refer to it many times. Implicit typing means that when no tag is written, the loader guesses the type from how the text looks: 42 becomes an integer and true becomes a boolean. Untrusted means any file you did not write and review yourself.

Loading happens in stages, and each problem in this tutorial lives in a different one:

YAML text --parser--> events --composer--> nodes --constructor--> objects
                                             ^
                              resolver gives every node a type tag
  • The constructor decides which tags may build which Python objects. Code execution lives here (Steps 2 to 4).
  • The composer assembles the nodes and resolves aliases, which is where a small file can describe a huge one (Step 5).
  • The resolver guesses the type of plain text, which is where NO becomes False (Step 7).

Duplicate keys (Step 6) slip through at the moment the constructor fills a dictionary. Keep the stages in mind, because each fix later plugs into the stage where its problem occurs.

What you will build, and what it does not do

By the end you will have:

  • measured, on PyYAML 6.0.3, which of its five loaders run code that a file asks for;
  • safeyaml.py, about 110 lines: a loader that only ever returns plain data, refuses Python tags, alias bombs, recursive aliases, duplicate keys, oversized files and bad encodings, and stops NO, 0755 and 2026-11-02 from changing type;
  • manifest.py, a short Pydantic schema that checks the shape of the data and rejects unknown keys;
  • 49 pytest tests and ten deliberate bugs that those tests catch;
  • yamlgate.py, a CI check that fails the build when anyone calls an object-building loader.

What this does not do: it does not make a file’s contents safe to act on. If your program reads a command name from a YAML file and runs it, that is your code’s decision, and you still need an allow-list. It covers PyYAML 6.0.3 only; other YAML libraries and other languages make different choices, so test yours the same way. And if you write every file yourself, safe_load is plenty.

Prerequisites

  • Python 3.10 or newer. Every command below ran on Python 3.13.14 on Windows 11. I read Requires-Python in each pinned package’s metadata: Pydantic 2.14.2 and pytest 9.2.0 need 3.10 or newer, ruamel.yaml 0.19.1 needs 3.9 or newer and PyYAML 6.0.3 needs 3.8 or newer.
  • A bash shell. On Windows I used Git Bash. Linux and macOS need nothing extra.
  • Packages: PyYAML 6.0.3, Pydantic 2.14.2, pytest 9.2.0, Ruff 0.17.3 and ruamel.yaml 0.19.1, pinned in Step 1.
  • Background: you can read a Python function, a dictionary and a list. No Docker, no cloud account and no real credentials are needed. The test documents are harmless: the only side effect any of them has is to create an empty folder inside a temporary folder.

Step 1: Set up the lab and check your versions

Make a folder and a virtual environment, which is a private set of installed packages for one project, so that nothing here touches your system Python. Then install the pinned packages:

mkdir yaml-lab && cd yaml-lab
python -m venv .venv
source .venv/bin/activate
pip install pyyaml==6.0.3 pydantic==2.14.2 pytest==9.2.0 ruff==0.17.3 ruamel.yaml==0.19.1

On Windows in Git Bash the activation line is source .venv/Scripts/activate. Pinning means asking for exact versions, so that your results match the ones printed below. Every file in this tutorial goes in this one folder, and the first line of each code block is a comment with its file name.

Start with a tiny script that reports what you are running:

# step01_versions.py - what are we running?
import platform

import yaml

print("Python  :", platform.python_version())
print("PyYAML  :", yaml.__version__)
print("libyaml :", yaml.__with_libyaml__)
names = ("BaseLoader", "SafeLoader", "FullLoader", "Loader", "UnsafeLoader")
print("Loaders :", ", ".join(name for name in names if hasattr(yaml, name)))

Run it with python step01_versions.py. Expected output:

Python  : 3.13.14
PyYAML  : 6.0.3
libyaml : True
Loaders : BaseLoader, SafeLoader, FullLoader, Loader, UnsafeLoader

Two lines deserve a comment. libyaml: True means this PyYAML build includes the C parser called libyaml, which a faster loader named CSafeLoader uses; Step 8 measures it. The Loaders line lists the five loader classes you are about to compare. If your PyYAML version differs, later tables might differ too, and the PyYAML changelog will tell you why.

Step 2: Watch a YAML file call a Python function

First a helper file that every later step shares. scratch() makes a throw-away folder, moves into it and deletes it afterwards, so whatever a test document does, it can only do inside that folder. PAYLOADS is a set of test documents that Step 3 explains, and bomb() builds the alias bomb of Step 5. Ignore those two for now.

# labkit.py - small helpers shared by the step scripts
import contextlib
import os
import shutil
import tempfile

# Documents used by several steps. The only side effect any of them can have is
# creating a folder named CANARY_RAN inside the scratch folder.
PAYLOADS = {
    "plain mapping": "{name: demo, size: 3}",
    "!!set": "!!set {a}",
    "!!python/tuple": "!!python/tuple [1, 2]",
    "!!python/module": "!!python/module:os",
    "!!python/name": "!!python/name:os.mkdir",
    "!!python/object/new": '!!python/object/new:os.mkdir ["CANARY_RAN"]',
    "!!python/object/apply": '!!python/object/apply:os.mkdir ["CANARY_RAN"]',
}


def bomb(levels, fan=9):
    """YAML where every level lists the previous level `fan` times, by alias."""
    lines = ["l0: &l0 [" + ", ".join(['"lol"'] * fan) + "]"]
    for n in range(1, levels + 1):
        lines.append(f"l{n}: &l{n} [" + ", ".join([f"*l{n - 1}"] * fan) + "]")
    return "\n".join(lines) + "\n"


@contextlib.contextmanager
def scratch():
    """Run inside a throw-away folder, then go back and delete it."""
    old = os.getcwd()
    folder = tempfile.mkdtemp(prefix="yamllab_")
    os.chdir(folder)
    try:
        yield folder
    finally:
        os.chdir(old)
        shutil.rmtree(folder, ignore_errors=True)

The only side effect any test document has is to create a folder named CANARY_RAN. A canary in a coal mine is the classic early warning, and this folder plays the same role: if it exists afterwards, the loader ran code from the file.

Now the first experiment. The document is a single line of YAML, and the loader is UnsafeLoader, which PyYAML names honestly:

# step02_danger.py - a YAML file that makes the loader call a Python function
import os

import yaml

from labkit import scratch

doc = '!!python/object/apply:os.mkdir ["CANARY_RAN"]\n'
print("YAML text:", doc.strip())

with scratch():
    print("folder exists before load:", os.path.isdir("CANARY_RAN"))
    result = yaml.load(doc, Loader=yaml.UnsafeLoader)
    print("load returned            :", result)
    print("folder exists after load :", os.path.isdir("CANARY_RAN"))

Run python step02_danger.py:

YAML text: !!python/object/apply:os.mkdir ["CANARY_RAN"]
folder exists before load: False
load returned            : None
folder exists after load : True

The file was not data; it was an instruction. The tag !!python/object/apply:os.mkdir means “import os, find mkdir and call it”, and the list ["CANARY_RAN"] after it is the argument list. The loader obeyed, the folder appeared, and load returned None because os.mkdir returns nothing. os.mkdir is harmless, but the same syntax can name any function that Python can import, including functions that start other programs. That is exactly what PyYAML’s warning about pickle.load means, and it is the weakness that CWE-502 describes as a product that “deserializes untrusted data without sufficiently ensuring that the resulting data will be valid.”

A common mistake is copying an old answer that says yaml.load(text, Loader=yaml.Loader). The name Loader is the dangerous one, as the next step shows.

Step 3: Find out which loaders are dangerous

PyYAML has five loaders, and not all of them are safe. This script feeds seven documents to each loader and reports one word per cell. RAN CODE means the canary folder appeared, refused means the loader raised an error, OBJECT means it built something other than plain data, and the other cells name the plain type that came back. Notice that os is imported at the top: FullLoader only looks up names in modules that the program has already imported.

# step03_matrix.py - which loaders turn Python tags into Python objects?
import os

import yaml

from labkit import PAYLOADS, scratch

COLUMNS = [("Base", "BaseLoader"), ("Safe", "SafeLoader"), ("Full", "FullLoader"),
           ("Loader", "Loader"), ("Unsafe", "UnsafeLoader")]


def attempt(loader_name, text):
    """Load `text`, then report what happened in one or two words."""
    with scratch():
        try:
            value = yaml.load(text, Loader=getattr(yaml, loader_name))
        except yaml.YAMLError:
            return "refused"
        if os.path.isdir("CANARY_RAN"):
            return "RAN CODE"
        if isinstance(value, (dict, list, set, tuple, str)):
            return type(value).__name__
        return "OBJECT"


print(f"{'document':<23}" + "".join(f"{label:<9}" for label, _ in COLUMNS))
for name, text in PAYLOADS.items():
    cells = [attempt(loader, text) for _, loader in COLUMNS]
    print(f"{name:<23}" + "".join(f"{cell:<9}" for cell in cells))

print()
print("FullLoader, !!python/name:os.mkdir ->",
      yaml.load(PAYLOADS["!!python/name"], Loader=yaml.FullLoader))

Run python step03_matrix.py:

document               Base     Safe     Full     Loader   Unsafe   
plain mapping          dict     dict     dict     dict     dict     
!!set                  dict     set      set      set      set      
!!python/tuple         list     refused  tuple    tuple    tuple    
!!python/module        str      refused  refused  OBJECT   OBJECT   
!!python/name          str      refused  OBJECT   OBJECT   OBJECT   
!!python/object/new    list     refused  refused  RAN CODE RAN CODE 
!!python/object/apply  list     refused  refused  RAN CODE RAN CODE 

FullLoader, !!python/name:os.mkdir -> <built-in function mkdir>

Read the columns from left to right. BaseLoader built nothing but dictionaries, lists and strings. SafeLoader accepted plain data and !!set and refused every Python tag, even the harmless !!python/tuple. FullLoader refused the two tags that call things (new and apply) and the module tag, but it built a tuple and handed back the function os.mkdir itself, as the last line shows. A function is not a call, yet it is far from plain data. Loader and UnsafeLoader behave identically here: both ran the code for new and for apply, and both gave back a module and a function object. The old name Loader is simply the unsafe loader.

PyYAML’s changelog explains how it got this way:

  • 5.1 (March 2019) deprecated yaml.load and added FullLoader and UnsafeLoader (pull request 257).
  • 5.3.1 (March 2020) prevented arbitrary code execution during the python/object/new constructor (pull request 386). The National Vulnerability Database describes CVE-2020-1747 as arbitrary code execution “through the full_load method or with the FullLoader loader” in versions before 5.3.1.
  • 5.4 (January 2021) fixed CVE-2020-14343 by moving the arbitrary Python tags to UnsafeLoader (pull request 472); NVD uses the same wording for versions before 5.4.
  • 6.0 (October 2021) made the Loader argument mandatory for yaml.load() (pull request 561).

The lesson is simple: of the loaders that return typed data, only SafeLoader refuses every Python tag, and yaml.safe_load is the function that uses it.

Step 4: Switch to safe_load and see what it refuses

yaml.safe_load(text) is the one-call form of loading with SafeLoader. This script runs all seven documents through it, then makes the mistake that PyYAML 6 turns into an error: calling yaml.load without saying which loader to use.

# step04_safe.py - the same documents through yaml.safe_load
import os
import textwrap

import yaml

from labkit import PAYLOADS, scratch

with scratch():
    for name, text in PAYLOADS.items():
        try:
            line = f"{name:<23}loaded {yaml.safe_load(text)!r}"
        except yaml.YAMLError as exc:
            line = f"{name:<23}{type(exc).__name__}: {exc.problem}"
        print(textwrap.fill(line, width=76, subsequent_indent=" " * 4))
    print("folder created:", os.path.isdir("CANARY_RAN"))

try:
    yaml.load("a: 1")
except TypeError as exc:
    print("yaml.load(text) ->", exc)

Run python step04_safe.py:

plain mapping          loaded {'name': 'demo', 'size': 3}
!!set                  loaded {'a'}
!!python/tuple         ConstructorError: could not determine a constructor
    for the tag 'tag:yaml.org,2002:python/tuple'
!!python/module        ConstructorError: could not determine a constructor
    for the tag 'tag:yaml.org,2002:python/module:os'
!!python/name          ConstructorError: could not determine a constructor
    for the tag 'tag:yaml.org,2002:python/name:os.mkdir'
!!python/object/new    ConstructorError: could not determine a constructor
    for the tag 'tag:yaml.org,2002:python/object/new:os.mkdir'
!!python/object/apply  ConstructorError: could not determine a constructor
    for the tag 'tag:yaml.org,2002:python/object/apply:os.mkdir'
folder created: False
yaml.load(text) -> load() missing 1 required positional argument: 'Loader'

Plain data and !!set load. Every Python tag raises ConstructorError with the message “could not determine a constructor for the tag”, which is how a safe loader says that it has no recipe for building such a thing. The line that matters most is folder created: False: nothing ran. The final line shows the PyYAML 6 rule from the changelog, that yaml.load needs a Loader argument, so old code that left it out now fails loudly instead of quietly running unsafe code.

How to verify this step: search your own code with grep -rn "yaml.load" src/ and read each hit. Step 11 replaces that manual search with an automatic gate.

This is where many tutorials stop, and for files you wrote yourself it is a fine place to stop. For files someone else wrote, safe_load has gaps. The next three steps show them with measurements, and Step 8 closes them.

Step 5: Meet aliases, and see why a small file can describe a huge one

An anchor names a value and an alias points back to it. They exist so that you can write a default once and reuse it. This script starts with the idea in its smallest form, a bomb with three levels and a fan-out of three, and proves that safe_load does not copy: the three entries of l1 are one and the same list. It then times larger bombs, a node that contains itself, and deep nesting. Each part prints under its own heading.

# step05_aliases.py - aliases let a small file describe a huge structure
import json
import time

import yaml

from labkit import bomb

print("=== a tiny bomb, three levels, fan-out 3")
print(bomb(2, fan=3), end="")
small = yaml.safe_load(bomb(2, fan=3))
print("l1[0] is l1[1]:", small["l1"][0] is small["l1"][1])

print("=== loading is quick, using the result is not")
print("levels   bytes       leaves  load ms  json.dumps ms  json MB")
for levels in (3, 4, 5, 6):
    text = bomb(levels)
    start = time.perf_counter()
    data = yaml.safe_load(text)
    load_ms = (time.perf_counter() - start) * 1000
    start = time.perf_counter()
    out = json.dumps(data[f"l{levels}"])
    dump_ms = (time.perf_counter() - start) * 1000
    print(f"{levels:>6} {len(text):>7,} {9 ** (levels + 1):>12,} {load_ms:>8.2f}"
          f" {dump_ms:>13.1f} {len(out) / 1e6:>8.1f}")

print("=== thirty levels")
text = bomb(30)
start = time.perf_counter()
data = yaml.safe_load(text)
load_ms = (time.perf_counter() - start) * 1000
print(f"{len(text):,} bytes loaded in {load_ms:.2f} ms")
print(f"leaves if you walk it: {9 ** 31:.2e}")

print("=== a node that contains itself")
loop = yaml.safe_load("&a [*a]")
print("loop[0] is loop:", loop[0] is loop)
try:
    json.dumps(loop)
except ValueError as exc:
    print("json.dumps ->", exc)

print("=== deep nesting without aliases")
for depth in (100, 5000):
    try:
        yaml.safe_load("[" * depth + "]" * depth)
        print(f"depth {depth:>5}: loaded")
    except RecursionError:
        print(f"depth {depth:>5}: RecursionError")

Run python step05_aliases.py and look at the first block:

l0: &l0 ["lol", "lol", "lol"]
l1: &l1 [*l0, *l0, *l0]
l2: &l2 [*l1, *l1, *l1]
l1[0] is l1[1]: True

Each level lists the previous level three times by alias. That is the whole trick, known from XML as the “billion laughs” pattern: every level multiplies the size of the data you would get if you wrote it all out, while the file grows by one short line. bomb() in labkit.py does the same with a fan-out of nine. Now the measurements:

levels   bytes       leaves  load ms  json.dumps ms  json MB
     3     234        6,561     0.44           0.2      0.0
     4     288       59,049     0.51           1.9      0.4
     5     342      531,441     0.59          17.4      3.9
     6     396    4,782,969     0.67         152.9     34.7

Loading is quick because PyYAML’s composer does not copy. When it meets an alias it returns the node that carries the anchor (return self.anchors[anchor] in composer.py), so nine references cost nine pointers. Using the result is another matter. json.dumps has to write every leaf, and each extra level multiplies that work by nine: level 6 produced 34.7 MB of JSON, and by my arithmetic level 8 would produce about 2.8 GB. Anything else that walks the structure pays the same price: logging it, templating it, copying it into another format, validating every element. Now the extreme case:

1,914 bytes loaded in 2.80 ms
leaves if you walk it: 3.82e+29

A 1,914-byte file loaded in a few milliseconds, and the structure it describes has about 3.82e+29 leaves if anything walks it. The loader is not what breaks; the first code that touches the result is.

Aliases can also point backwards. The YAML specification says that “a sequence may contain the same node more than once. It could even contain itself.” PyYAML allows it:

loop[0] is loop: True
json.dumps -> Circular reference detected

The list &a [*a] loads into a list whose first item is the list itself, and json.dumps refuses it with Circular reference detected. Recursive code that does not remember what it has already visited will recurse until Python raises a RecursionError. Finally, nesting without any aliases:

depth   100: loaded
depth  5000: RecursionError

One hundred nested lists load, and five thousand raise RecursionError. That is an ordinary Python exception rather than a crash, but a service that does not catch it returns an error page for one bad request. Remember all three behaviours for Step 8.

Step 6: Catch the key that appears twice

The YAML specification says that mapping keys must be unique: “The content of a mapping node is an unordered set of key/value node pairs, with the restriction that each of the keys is unique.” That is a rule for the file’s author, and PyYAML does not enforce it when loading. Here is what that means for a settings file:

# step06_duplicates.py - when a key appears twice, the last one wins, silently
import yaml

doc = """\
sandbox:
  network: false
  allowed_hosts: [pypi.org]
sandbox:
  network: true
"""
print(doc, end="")
print("-> safe_load gives:", yaml.safe_load(doc))

Run python step06_duplicates.py:

sandbox:
  network: false
  allowed_hosts: [pypi.org]
sandbox:
  network: true
-> safe_load gives: {'sandbox': {'network': True}}

The first sandbox block says network: false and lists an allowed host. The second sandbox block silently replaces the whole first one, so the loaded settings say network: true and the host list is gone. A reviewer who reads the top of the file sees a locked-down sandbox, while the program uses the bottom. Anyone who can add one more block to the file can change a setting without touching the lines a reviewer is looking at. Step 8 makes the loader refuse duplicates.

Step 7: See what safe_load guesses when you do not use quotes

When a value has no tag, the resolver decides its type by matching the text against patterns. PyYAML follows the older YAML 1.1 rules, and one of them is this list of boolean words from the YAML 1.1 boolean type: y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF. YAML 1.2 narrowed this. Its core schema recognises only true, True, TRUE, false, False and FALSE, and the specification calls that schema “the recommended default schema that YAML processor should use unless instructed otherwise.” PyYAML’s output for 14 plain values shows how far it still is from that:

# step07_surprises.py - what yaml.safe_load guesses from unquoted text
import yaml

SCALARS = ["NO", "yes", "on", "y", "3.10", "0755", "010", "1:30", "1_000",
           "2026-11-02", "~", "1e3", "0x1F", ".inf"]

print(f"{'YAML text':<12}{'safe_load gives':<30}type")
for text in SCALARS:
    value = yaml.safe_load(text)
    print(f"{text:<12}{value!r:<30}{type(value).__name__}")

print()
print(yaml.safe_load("countries: [GB, NO, SE]"))
print(yaml.safe_load("on: push"))

Run python step07_surprises.py:

YAML text   safe_load gives               type
NO          False                         bool
yes         True                          bool
on          True                          bool
y           'y'                           str
3.10        3.1                           float
0755        493                           int
010         8                             int
1:30        90                            int
1_000       1000                          int
2026-11-02  datetime.date(2026, 11, 2)    date
~           None                          NoneType
1e3         '1e3'                         str
0x1F        31                            int
.inf        inf                           float

{'countries': ['GB', False, 'SE']}
{True: 'push'}

Go down the first column. NO is False, the famous Norway problem: the list [GB, NO, SE] of country codes arrives as ['GB', False, 'SE'], as the second-to-last line shows. yes and on are True, so in on: push the key is the boolean True, not the text on. (y stays a string: PyYAML implements the long words but not the one-letter forms that the 1.1 page lists.) 0755 is 493 because a leading zero means octal, and 010 is 8 for the same reason, so an identifier such as a zip code written without quotes changes value. 1:30 is 90 because the colon form is base 60. 1_000 is 1000 and 0x1F is 31. 2026-11-02 becomes a date object, so a plain-text field that happens to look like a date stops being text. And 3.10 is the float 3.1, so a Python version or a release number written without quotes loses its trailing zero. That last one happens in YAML 1.1 and in 1.2 alike, so no loader can fix it; only quotes or a schema can.

Step 8: Build a loader that only returns plain data

You now have a list of problems and you know which stage each one lives in, so you can fix each in its own stage. The loader goes in one file, safeyaml.py. Put the five code blocks below into it in order; the first line of each block after the first, which says (continued), only marks where one block ends and the next begins and is not part of the file.

8a. Limits and one error type

# safeyaml.py - a YAML loader for files that someone else wrote
import re
from collections.abc import Hashable

import yaml
from yaml.constructor import ConstructorError
from yaml.nodes import ScalarNode, SequenceNode

MAX_BYTES = 64 * 1024     # refuse bigger files before parsing anything
MAX_NODES = 50_000        # budget for the document after aliases are expanded

TAG = "tag:yaml.org,2002:"
ALLOWED_TAGS = {TAG + name
                for name in ("null", "bool", "int", "float", "str", "seq", "map")}


class YamlRejected(ValueError):
    """The file is not acceptable. The message says why, in plain words."""

There are two budgets: a file may not exceed 64 KiB, and once aliases are expanded the document may not exceed 50,000 nodes. A node is one scalar, list or dictionary in the parsed tree, and the composer produces them. ALLOWED_TAGS lists the only seven types we accept: null, bool, int, float, str, seq (a list) and map (a dictionary). YamlRejected is the one exception your program has to catch, and it is a ValueError.

8b. Check the whole document before building any of it

# safeyaml.py (continued)
def check_nodes(root, max_nodes):
    """Walk the node graph once. Reject forbidden tags, loops and huge expansions."""
    sizes = {}        # id(node) -> how many nodes it has once aliases are expanded
    inside = set()    # ids of the nodes we are currently inside of

    def walk(node):
        key = id(node)
        if key in sizes:
            return sizes[key]
        if key in inside:
            raise YamlRejected("recursive alias: a node contains itself")
        if node.tag not in ALLOWED_TAGS:
            raise YamlRejected(f"tag {node.tag!r} is not allowed")
        inside.add(key)
        if isinstance(node, ScalarNode):
            size = 1
        elif isinstance(node, SequenceNode):
            size = 1 + sum(walk(child) for child in node.value)
        else:
            size = 1 + sum(walk(k) + walk(v) for k, v in node.value)
        inside.discard(key)
        if size > max_nodes:
            raise YamlRejected(f"document expands to more than {max_nodes:,} nodes")
        sizes[key] = size
        return size

    return walk(root)

This function is the heart of the defence. It walks the node graph once and does three jobs. It refuses any node whose tag is not on the list, so a Python tag is rejected here, before the constructor ever sees it. It refuses a node that contains itself, detected with the set inside, which holds the nodes we are currently in. And it works out how large each node would be if every alias were expanded, storing the answer by id(node) in sizes. That memo is what keeps it fast. The 30-level bomb has only 72 distinct nodes, so the function visits each node at most once and stops at the first level whose expanded size is over the budget. It never builds the huge structure; it only does the arithmetic.

8c. The loader class

# safeyaml.py (continued)
class StrictLoader(yaml.SafeLoader):
    """SafeLoader that also checks the whole document before building anything."""

    max_nodes = MAX_NODES

    def get_single_node(self):
        node = super().get_single_node()
        if node is not None:
            check_nodes(node, self.max_nodes)
        return node

    def construct_mapping(self, node, deep=False):
        seen = set()
        for key_node, _ in node.value:
            key = self.construct_object(key_node, deep=True)
            if not isinstance(key, Hashable):
                continue                  # SafeLoader reports unhashable keys itself
            if key in seen:
                raise ConstructorError("while constructing a mapping", node.start_mark,
                                       f"found duplicate key {key!r}", key_node.start_mark)
            seen.add(key)
        return super().construct_mapping(node, deep=deep)

StrictLoader starts from SafeLoader, so it begins with no Python constructors at all. get_single_node is the method PyYAML calls to get the document’s root node, so overriding it lets us run check_nodes after the document is parsed and before anything is constructed. construct_mapping runs every time a dictionary is built. We construct each key, remember the keys we have seen, and raise PyYAML’s own ConstructorError, pointing at the second key, when one repeats. Because it compares the constructed Python values, 1 and 1.0 count as the same key, which suits a settings file.

8d. Replace the type guesses

# safeyaml.py (continued)
# YAML 1.1 lets plain text turn into booleans, dates, octal and base-60 numbers.
# Replace those guesses with JSON-like ones: only true/false, null and plain numbers.
StrictLoader.yaml_implicit_resolvers = {}
for _tag, _pattern, _first in [
    (TAG + "null", r"^(?:~|null|Null|NULL|)$", ["~", "n", "N", ""]),
    (TAG + "bool", r"^(?:true|True|TRUE|false|False|FALSE)$", list("tTfF")),
    (TAG + "int", r"^-?(?:0|[1-9][0-9]*)$", list("-0123456789")),
    (TAG + "float", r"^-?(?:0|[1-9][0-9]*)"
                    r"(?:\.[0-9]+(?:[eE][-+]?[0-9]+)?|[eE][-+]?[0-9]+)$", list("-0123456789")),
]:
    StrictLoader.add_implicit_resolver(_tag, re.compile(_pattern), _first)

This is the resolver fix. Assigning an empty dictionary to yaml_implicit_resolvers throws away all of YAML 1.1’s guesses for this class, and the loop adds four back. Each rule is a tag, a regular expression and the list of first characters worth trying; PyYAML only runs a rule when the text starts with one of them. The result is deliberately close to JSON. null, ~ and an empty value are null; only true and false in three spellings are booleans; integers have no sign except a minus, no leading zeros, no underscores and no colons; floats need a dot or an exponent. Everything else is a string, including NO, 0755, 1:30 and 2026-11-02. This is narrower than the YAML 1.2 core schema, which also allows a plus sign, octal and hexadecimal integers and infinity, because a settings file rarely needs them. And because the merge-key rule is gone, << is no longer special either: it is just a string key that your schema will reject.

One detail worth knowing: the dictionary is assigned on StrictLoader itself, so plain yaml.safe_load elsewhere in your program keeps its old behaviour. A test in Step 10 checks that.

8e. The entry points

# safeyaml.py (continued)
def load_text(data):
    """Parse YAML from an untrusted source into plain Python data."""
    if isinstance(data, str):
        data = data.encode("utf-8")
    if len(data) > MAX_BYTES:
        raise YamlRejected(f"{len(data):,} bytes is over the {MAX_BYTES:,} byte limit")
    try:
        text = data.decode("utf-8-sig")
    except UnicodeDecodeError as exc:
        raise YamlRejected(f"not valid UTF-8 ({exc.reason})") from None
    try:
        # Ruff cannot see that StrictLoader is a SafeLoader subclass, so it objects.
        return yaml.load(text, Loader=StrictLoader)  # noqa: S506
    except yaml.YAMLError as exc:
        words = [getattr(exc, "context", None), getattr(exc, "problem", None)]
        message = ", ".join(w for w in words if w) or str(exc).splitlines()[0]
        mark = getattr(exc, "problem_mark", None)
        where = f" (line {mark.line + 1})" if mark else ""
        raise YamlRejected(message + where) from None
    except RecursionError:
        raise YamlRejected("nesting is too deep") from None


def load_file(path):
    """Read at most one byte past the limit, so a huge file is never read whole."""
    with open(path, "rb") as handle:
        return load_text(handle.read(MAX_BYTES + 1))

load_text accepts text or bytes and refuses a file over the byte limit before parsing it. It then decodes the bytes as strict UTF-8; PyYAML’s own documentation says it also accepts UTF-16, which is one more way for a file to look different to a reviewer than it does to a program. Anything PyYAML complains about becomes YamlRejected with its line number when PyYAML gives one, and RecursionError becomes “nesting is too deep”. load_file reads at most one byte beyond the limit, so a multi-gigabyte file is never read into memory. The # noqa: S506 comment silences one Ruff rule on one line, and the comment above it says why; Step 11 explains the rule.

8f. Try it on every nasty document

This script sends the seven documents from Step 3, the bomb, the loop, a duplicate key, two documents in one file and deep nesting through load_text:

# step08_guard.py - the same documents through the strict loader
import textwrap
import time

import yaml

from labkit import PAYLOADS, bomb, scratch
from safeyaml import StrictLoader, YamlRejected, load_text

DOCS = {
    **PAYLOADS,
    "alias bomb, 30 levels": bomb(30),
    "alias to itself": "&a [*a]",
    "duplicate key": "a: 1\na: 2\n",
    "two documents": "a: 1\n---\nb: 2\n",
    "deep nesting": "[" * 5000 + "]" * 5000,
}

with scratch():
    for name, text in DOCS.items():
        try:
            line = f"{name:<23}accepted {load_text(text)!r}"
        except YamlRejected as exc:
            line = f"{name:<23}REJECTED {exc}"
        print(textwrap.fill(line, width=76, subsequent_indent=" " * 4))

start = time.perf_counter()
try:
    load_text(bomb(30))
except YamlRejected:
    pass
print(f"the 30-level bomb was refused in {(time.perf_counter() - start) * 1000:.1f} ms")

print("=== what the checks cost on an honest file")
honest = "".join(f"- {{id: {n}, name: item-{n}, tags: [a, b], ok: true}}\n" for n in range(1000))
print(f"{len(honest.encode()):,} bytes, 1000 mappings")
for label, loader in (("CSafeLoader (libyaml)", yaml.CSafeLoader),
                      ("SafeLoader", yaml.SafeLoader), ("StrictLoader", StrictLoader)):
    runs = []
    for _ in range(5):
        start = time.perf_counter()
        yaml.load(honest, Loader=loader)
        runs.append(time.perf_counter() - start)
    print(f"{label:<22}{min(runs) * 1000:>7.1f} ms")

Run python step08_guard.py:

plain mapping          accepted {'name': 'demo', 'size': 3}
!!set                  REJECTED tag 'tag:yaml.org,2002:set' is not allowed
!!python/tuple         REJECTED tag 'tag:yaml.org,2002:python/tuple' is not
    allowed
!!python/module        REJECTED tag 'tag:yaml.org,2002:python/module:os' is
    not allowed
!!python/name          REJECTED tag 'tag:yaml.org,2002:python/name:os.mkdir'
    is not allowed
!!python/object/new    REJECTED tag
    'tag:yaml.org,2002:python/object/new:os.mkdir' is not allowed
!!python/object/apply  REJECTED tag
    'tag:yaml.org,2002:python/object/apply:os.mkdir' is not allowed
alias bomb, 30 levels  REJECTED document expands to more than 50,000 nodes
alias to itself        REJECTED recursive alias: a node contains itself
duplicate key          REJECTED while constructing a mapping, found
    duplicate key 'a' (line 2)
two documents          REJECTED expected a single document in the stream,
    but found another document (line 2)
deep nesting           REJECTED nesting is too deep
the 30-level bomb was refused in 2.7 ms

Every dangerous document is rejected with a sentence that a person can act on, and the bomb is refused in a few milliseconds without ever building its leaves. The plain mapping is still accepted. Note that !!set, which safe_load allowed, is now refused: the policy is plain data only, and a settings file has no use for sets. If you need more, add a tag to ALLOWED_TAGS on purpose.

The same script ends by timing the loaders on an honest 52 KB file of 1,000 small mappings:

51,780 bytes, 1000 mappings
CSafeLoader (libyaml)    16.2 ms
SafeLoader              114.8 ms
StrictLoader            117.2 ms

The strict checks added only a few milliseconds to SafeLoader, but both pure-Python loaders took about seven times as long as libyaml’s CSafeLoader on the same file. For a settings file read once at start-up, about a tenth of a second is fine. If you parse large files in a hot path, use CSafeLoader only for files you trust.

8g. Compare three loaders side by side

One more script shows the effect of the resolver change, with ruamel.yaml as a third column. Its PyPI page describes it as “a YAML 1.2 loader/dumper package for Python”, and I used it only to see what a 1.2 loader does with the same text.

# step08b_compare.py - the same plain text through three loaders
import datetime

import yaml

from safeyaml import StrictLoader

try:
    from ruamel.yaml import YAML
    ruamel = YAML(typ="safe", pure=True)
except ImportError:
    ruamel = None

SCALARS = ["NO", "yes", "on", "y", "3.10", "0755", "1:30", "1_000",
           "2026-11-02", "~", "1e3", "0x1F", ".inf", "-0.5"]


def short(value):
    if isinstance(value, datetime.date):
        return f"date {value.isoformat()}"
    return repr(value)


print(f"{'YAML text':<12}{'safe_load':<20}{'StrictLoader':<20}ruamel.yaml")
for text in SCALARS:
    old = yaml.safe_load(text)
    new = yaml.load(text, Loader=StrictLoader)
    other = ruamel.load(text) if ruamel else "n/a"
    print(f"{text:<12}{short(old):<20}{short(new):<20}{short(other)}")

Run python step08b_compare.py:

YAML text   safe_load           StrictLoader        ruamel.yaml
NO          False               'NO'                'NO'
yes         True                'yes'               'yes'
on          True                'on'                'on'
y           'y'                 'y'                 'y'
3.10        3.1                 3.1                 3.1
0755        493                 '0755'              755
1:30        90                  '1:30'              '1:30'
1_000       1000                '1_000'             1000
2026-11-02  date 2026-11-02     '2026-11-02'        date 2026-11-02
~           None                None                None
1e3         '1e3'               1000.0              1000.0
0x1F        31                  '0x1F'              31
.inf        inf                 '.inf'              inf
-0.5        -0.5                -0.5                -0.5

The strict loader keeps NO, yes, on, 0755, 1:30, 1_000, 0x1F, .inf and the date as text, and keeps the JSON-style numbers. The YAML 1.2 loader fixes the boolean words and the base-60 number, but it still reads 0755 as the integer 755, 1_000 as 1000 and the date as a date. And all three turn 3.10 into 3.1, which is why the next step adds a schema.

Step 9: Check the shape of the data with a schema

A loader can only decide what a piece of text is; it cannot decide what your program expects. For that you need a schema, a description of the allowed keys and their types. Pydantic is a library that builds one from a Python class. The class below describes a plugin manifest. It rejects keys it does not know (extra="forbid") and accepts only a real boolean for enabled (StrictBool).

# manifest.py - the shape of a plugin manifest, checked after the YAML is loaded
from datetime import date

from pydantic import BaseModel, ConfigDict, Field, StrictBool, ValidationError

from safeyaml import YamlRejected, load_file


class Plugin(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: str = Field(pattern=r"^[a-z][a-z0-9-]{1,40}$")
    version: str
    python_version: str
    enabled: StrictBool = False
    countries: list[str] = []
    released: date | None = None


def describe(exc):
    """One short sentence per problem, with the value that was found."""
    parts = []
    for error in exc.errors():
        where = ".".join(str(p) for p in error["loc"]) or "document"
        parts.append(f"{where}: {error['msg']} (got {error['input']!r:.30})")
    return "; ".join(parts)


def load_plugin(path):
    data = load_file(path)
    try:
        return Plugin.model_validate(data)
    except ValidationError as exc:
        raise YamlRejected(describe(exc)) from None

The important design choice is that version and python_version are declared as strings. Pydantic’s conversion table says that a number is accepted as a string “Only if coerce_numbers_to_str is enabled.” We do not enable it, so an unquoted 3.10, which the loader reads as the float 3.1, is refused with a message that shows the author what was found, instead of being silently stored as 3.1. The date is the opposite case: released is declared as a date, and Pydantic parses the text 2026-11-02 into one, so the resolver can leave dates as text while the schema turns them into real dates where you want them.

Now six small files, one good and five bad. The script writes them into a scratch folder and loads each with load_plugin:

# step09_files.py - six files, one loader
import os
import textwrap
from pathlib import Path

from labkit import bomb, scratch
from manifest import load_plugin
from safeyaml import YamlRejected

FILES = {
    "good.yaml": ('name: geo-fence\nversion: "1.10"\npython_version: "3.10"\n'
                  'enabled: true\ncountries: [GB, NO, SE]\nreleased: 2026-11-02\n'),
    "sloppy.yaml": ("name: geo-fence\nversion: 1.10\npython_version: 3.10\n"
                    "enabled: yes\ncountries: [GB, NO, SE]\n"),
    "tagged.yaml": ('name: !!python/object/apply:os.mkdir ["CANARY_RAN"]\n'
                    'version: "1"\npython_version: "3.13"\n'),
    "bomb.yaml": bomb(30),
    "dupes.yaml": 'name: geo-fence\nversion: "1"\nversion: "2"\npython_version: "3.13"\n',
    "extra.yaml": 'name: geo-fence\nversion: "1"\npython_version: "3.13"\nrun_on_install: true\n',
}

with scratch():
    for name, text in FILES.items():
        Path(name).write_text(text, encoding="utf-8", newline="\n")
        try:
            plugin = load_plugin(name)
            verdict = (f"accepted  {plugin.name} {plugin.version} "
                       f"py{plugin.python_version} {plugin.countries}")
        except YamlRejected as exc:
            verdict = f"REJECTED  {exc}"
        print(textwrap.fill(f"{name:<12}{verdict}", width=76, subsequent_indent=" " * 12))
    print("CANARY_RAN created:", os.path.exists("CANARY_RAN"))

Run python step09_files.py:

good.yaml   accepted  geo-fence 1.10 py3.10 ['GB', 'NO', 'SE']
sloppy.yaml REJECTED  version: Input should be a valid string (got 1.1);
            python_version: Input should be a valid string (got 3.1);
            enabled: Input should be a valid boolean (got 'yes')
tagged.yaml REJECTED  tag 'tag:yaml.org,2002:python/object/apply:os.mkdir'
            is not allowed
bomb.yaml   REJECTED  document expands to more than 50,000 nodes
dupes.yaml  REJECTED  while constructing a mapping, found duplicate key
            'version' (line 3)
extra.yaml  REJECTED  run_on_install: Extra inputs are not permitted (got
            True)
CANARY_RAN created: False

The good file loads with its versions still exact strings and with NO intact in the country list. The sloppy file, which is what a hurried author would write, produces three precise messages: the author sees got 1.1 and got 3.1 and learns to quote the values. The file with a Python tag, the bomb and the duplicate key are refused by the loader, and the file with an unknown key is refused by the schema. The last line shows that the canary folder never appeared.

Step 10: Test every promise, then break the loader on purpose

A security control that is not tested tends to stop working without anyone noticing. This file has one test per promise made above. A few details are worth knowing. monkeypatch.chdir moves each Python-tag test into pytest’s own temporary folder, so a failing test can never create the canary folder in your project. The bomb test also asserts that the refusal takes under a second. And test_global_safe_load_is_untouched proves that building StrictLoader did not change yaml.safe_load for the rest of your program.

# test_safeyaml.py - the promises of safeyaml.py and manifest.py, one test each
import datetime
import time

import pytest
import yaml

from labkit import bomb
from manifest import load_plugin
from safeyaml import MAX_BYTES, StrictLoader, YamlRejected, load_file, load_text

PYTHON_TAGS = [
    '!!python/object/apply:os.mkdir ["CANARY_RAN"]',
    '!!python/object/new:os.mkdir ["CANARY_RAN"]',
    "!!python/name:os.mkdir",
    "!!python/module:os",
    "!!python/tuple [1, 2]",
]
GOOD = ('name: geo-fence\nversion: "1.10"\npython_version: "3.10"\n'
        'enabled: true\ncountries: [GB, NO, SE]\nreleased: 2026-11-02\n')
SLOPPY = ("name: geo-fence\nversion: 1.10\npython_version: 3.10\n"
          "enabled: yes\ncountries: [GB, NO, SE]\n")


def test_plain_data_loads():
    text = "name: demo\nsizes: [1, 2.5, true, null]\n"
    assert load_text(text) == {"name": "demo", "sizes": [1, 2.5, True, None]}


@pytest.mark.parametrize("text", PYTHON_TAGS)
def test_python_tags_are_refused(text, tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    with pytest.raises(YamlRejected, match="not allowed"):
        load_text(text)
    assert not (tmp_path / "CANARY_RAN").exists()


@pytest.mark.parametrize("text", ["!!binary aGk=", "!!set {a, b}",
                                  "!!timestamp 2026-11-02", "!!omap [a: 1]"])
def test_other_explicit_tags_are_refused(text):
    with pytest.raises(YamlRejected, match="not allowed"):
        load_text(text)


def test_no_python_constructors_are_registered():
    assert not [tag for tag in StrictLoader.yaml_constructors if tag and "python" in tag]
    assert not StrictLoader.yaml_multi_constructors


def test_alias_bomb_is_refused_quickly():
    start = time.perf_counter()
    with pytest.raises(YamlRejected, match="expands to more than"):
        load_text(bomb(30))
    assert time.perf_counter() - start < 1.0


def test_modest_alias_use_is_allowed():
    data = load_text("defaults: &d {retries: 3}\nprod: *d\nstaging: *d\n")
    assert data["prod"] == data["staging"] == {"retries": 3}


def test_recursive_alias_is_refused():
    with pytest.raises(YamlRejected, match="recursive"):
        load_text("&a [*a]\n")


def test_duplicate_key_is_refused_with_its_line_number():
    text = "sandbox:\n  network: false\n  hosts: [a]\nsandbox:\n  network: true\n"
    with pytest.raises(YamlRejected, match=r"duplicate key 'sandbox' \(line 4\)"):
        load_text(text)


def test_duplicate_key_in_a_nested_mapping_is_refused():
    with pytest.raises(YamlRejected, match="duplicate key 'a'"):
        load_text("outer:\n  a: 1\n  a: 2\n")


def test_same_key_in_different_mappings_is_fine():
    assert load_text("x: {a: 1}\ny: {a: 2}\n") == {"x": {"a": 1}, "y": {"a": 2}}


def test_merge_key_is_an_ordinary_string():
    data = load_text("base: &b {a: 1}\nchild:\n  <<: *b\n  c: 2\n")
    assert data["child"] == {"<<": {"a": 1}, "c": 2}


@pytest.mark.parametrize("text", ["NO", "no", "yes", "Yes", "on", "Off", "y", "n"])
def test_yaml11_words_stay_strings(text):
    assert load_text(text) == text


def test_the_norway_list():
    assert load_text("countries: [GB, NO, SE]") == {"countries": ["GB", "NO", "SE"]}


def test_booleans_and_null_use_the_json_words():
    assert load_text("[true, True, FALSE, false, null, ~]") == [True, True, False, False, None, None]
    assert load_text("a:\n") == {"a": None}


@pytest.mark.parametrize("text", ["0755", "010", "1:30", "1_000", "0x1F", "+1",
                                  ".inf", "2026-11-02", "0o17"])
def test_odd_numbers_and_dates_stay_strings(text):
    assert load_text(text) == text


def test_json_style_numbers_still_work():
    assert load_text("[0, -3, 42, 2.5, 1e3, -0.5]") == [0, -3, 42, 2.5, 1000.0, -0.5]


def test_version_like_numbers_are_still_numbers():
    assert load_text("3.10") == 3.1      # no loader can know you meant a string


def test_size_limit_boundary():
    ok = "a: " + "x" * (MAX_BYTES - 4) + "\n"
    assert len(ok) == MAX_BYTES
    assert load_text(ok)["a"] == "x" * (MAX_BYTES - 4)
    with pytest.raises(YamlRejected, match="byte limit"):
        load_text(ok + "#")


def test_load_file_refuses_a_huge_file(tmp_path):
    path = tmp_path / "big.yaml"
    path.write_bytes(b"#" * (MAX_BYTES * 4))
    with pytest.raises(YamlRejected, match="byte limit"):
        load_file(path)


def test_utf16_and_bad_bytes_are_refused():
    with pytest.raises(YamlRejected, match="UTF-8"):
        load_text("a: 1".encode("utf-16"))
    with pytest.raises(YamlRejected, match="UTF-8"):
        load_text(b"a: \xff\xfe\n")


def test_two_documents_are_refused():
    with pytest.raises(YamlRejected, match="single document"):
        load_text("a: 1\n---\nb: 2\n")


def test_deep_nesting_is_refused_not_crashed():
    with pytest.raises(YamlRejected, match="too deep"):
        load_text("[" * 5000 + "]" * 5000)


def test_syntax_error_names_a_line():
    with pytest.raises(YamlRejected, match=r"\(line \d+\)"):
        load_text("a: 1\nb: [1, 2\n")


def test_global_safe_load_is_untouched():
    assert yaml.safe_load("NO") is False
    assert yaml.safe_load("2026-11-02") == datetime.date(2026, 11, 2)


def test_good_manifest_loads(tmp_path):
    path = tmp_path / "plugin.yaml"
    path.write_text(GOOD, encoding="utf-8")
    plugin = load_plugin(path)
    assert (plugin.version, plugin.python_version) == ("1.10", "3.10")
    assert plugin.countries == ["GB", "NO", "SE"]
    assert plugin.released.isoformat() == "2026-11-02"


def test_sloppy_manifest_names_every_problem(tmp_path):
    path = tmp_path / "plugin.yaml"
    path.write_text(SLOPPY, encoding="utf-8")
    with pytest.raises(YamlRejected) as info:
        load_plugin(path)
    message = str(info.value)
    assert "version: Input should be a valid string (got 1.1)" in message
    assert "python_version: Input should be a valid string (got 3.1)" in message
    assert "enabled: Input should be a valid boolean (got 'yes')" in message


def test_unknown_manifest_keys_are_refused(tmp_path):
    path = tmp_path / "plugin.yaml"
    path.write_text(GOOD + "run_on_install: true\n", encoding="utf-8")
    with pytest.raises(YamlRejected, match="run_on_install: Extra inputs are not permitted"):
        load_plugin(path)

Run python -m pytest -q:

.................................................                                            [100%]
49 passed in 0.71s

Passing tests only show that the code does what the tests say. To find out whether the tests would notice a mistake, mutation testing changes the code on purpose and checks that some test fails. This script copies the files to a temporary folder, applies one of ten small changes (accept any tag, drop the expansion budget, allow duplicate keys, keep the YAML 1.1 guesses, build on UnsafeLoader and so on) and runs pytest, expecting a failure each time. It also asserts that each pattern occurs exactly once, so a typo cannot turn a mutant into a no-op.

# mutations.py - break the loader on purpose and make sure the tests notice
import pathlib
import re
import shutil
import subprocess
import sys
import tempfile

HERE = pathlib.Path(__file__).parent
FILES = ["safeyaml.py", "manifest.py", "labkit.py", "test_safeyaml.py"]
MUTANTS = [
    ("accept any tag", "safeyaml.py", "if node.tag not in ALLOWED_TAGS:", "if False:"),
    ("no expansion budget", "safeyaml.py", "if size > max_nodes:", "if False:"),
    ("allow recursive alias", "safeyaml.py", "if key in inside:", "if False:"),
    ("allow duplicate keys", "safeyaml.py", "if key in seen:", "if False:"),
    ("keep YAML 1.1 guesses", "safeyaml.py",
     "StrictLoader.yaml_implicit_resolvers = {}", "pass"),
    ("size limit off by one", "safeyaml.py",
     "if len(data) > MAX_BYTES:", "if len(data) >= MAX_BYTES:"),
    ("build on UnsafeLoader", "safeyaml.py",
     "class StrictLoader(yaml.SafeLoader):", "class StrictLoader(yaml.UnsafeLoader):"),
    ("lenient UTF-8", "safeyaml.py",
     'data.decode("utf-8-sig")', 'data.decode("utf-8", errors="replace")'),
    ("ignore unknown keys", "manifest.py", 'extra="forbid"', 'extra="ignore"'),
    ("lax booleans", "manifest.py", "enabled: StrictBool", "enabled: bool"),
]


def run(mutant):
    with tempfile.TemporaryDirectory() as tmp:
        for name in FILES:
            shutil.copy(HERE / name, tmp)
        if mutant:
            _, name, old, new = mutant
            path = pathlib.Path(tmp) / name
            text = path.read_text(encoding="utf-8")
            assert text.count(old) == 1, (name, old, text.count(old))
            path.write_text(text.replace(old, new), encoding="utf-8", newline="\n")
        result = subprocess.run(
            [sys.executable, "-m", "pytest", "-q", "-x", "-p", "no:cacheprovider", tmp],
            capture_output=True, text=True)
    failed = re.search(r"^FAILED \S+?::(\w+)", result.stdout, re.M)
    return result.returncode, failed.group(1) if failed else ""


code, _ = run(None)
print(f"{'unchanged code':<24}{'tests pass' if code == 0 else 'TESTS FAIL'}")
caught = 0
for mutant in MUTANTS:
    code, test = run(mutant)
    caught += code != 0
    print(f"{mutant[0]:<24}{'caught by ' + test if code else 'NOT CAUGHT'}")
print(f"{caught} of {len(MUTANTS)} planted bugs were caught")

Run python mutations.py; it takes a few seconds per mutant:

unchanged code          tests pass
accept any tag          caught by test_python_tags_are_refused
no expansion budget     caught by test_alias_bomb_is_refused_quickly
allow recursive alias   caught by test_recursive_alias_is_refused
allow duplicate keys    caught by test_duplicate_key_is_refused_with_its_line_number
keep YAML 1.1 guesses   caught by test_merge_key_is_an_ordinary_string
size limit off by one   caught by test_size_limit_boundary
build on UnsafeLoader   caught by test_no_python_constructors_are_registered
lenient UTF-8           caught by test_utf16_and_bad_bytes_are_refused
ignore unknown keys     caught by test_unknown_manifest_keys_are_refused
lax booleans            caught by test_sloppy_manifest_names_every_problem
10 of 10 planted bugs were caught

All ten were caught. If one had survived, it would point to a promise with no test, and you would write that test before trusting the loader. Notice that the mutant that builds on UnsafeLoader is caught by the test that inspects the registered constructors, not by the tag-refusal tests, because the tag allow-list would still have refused the Python tags. That test is what proves the base class itself is safe: two layers, each checked by its own test.

Step 11: Stop the next unsafe call before it merges

Someone will eventually write yaml.load(text, Loader=yaml.Loader) again, perhaps pasted from an old answer, perhaps written by a coding assistant. A linter is a tool that reads source code and reports suspicious patterns. Ruff has a rule for this, S506, and its documentation explains the risk: “Running the yaml.load function over untrusted YAML files is insecure, as yaml.load allows for the creation of arbitrary Python objects, which can then be used to execute arbitrary code.” A rule is only as good as its coverage, so measure it. This file lists 14 ways to call the loader, with the verdict I expect in a comment on each line:

# gate_samples.py - fourteen ways to call the YAML loader (read by step11_gate.py, never run)
import yaml
import yaml as y
from safeyaml import StrictLoader
from yaml import FullLoader as FL
from yaml import Loader, SafeLoader, load
from yaml import load as yload

chosen_loader = yaml.SafeLoader
text = "a: 1"

yaml.safe_load(text)                                # expect: ok
yaml.load(text, Loader=yaml.SafeLoader)             # expect: ok
load(text, Loader=SafeLoader)                       # expect: ok
yaml.load(text, Loader=StrictLoader)                # expect: ok
yaml.load(text)                                     # expect: bad
yaml.load(text, Loader=yaml.Loader)                 # expect: bad
yaml.load(text, yaml.FullLoader)                    # expect: bad
yaml.full_load(text)                                # expect: bad
yaml.unsafe_load(text)                              # expect: bad
y.load(text, Loader=y.UnsafeLoader)                 # expect: bad
load(text, Loader=Loader)                           # expect: bad
yload(text, Loader=FL)                              # expect: bad
yaml.load_all(text, Loader=yaml.FullLoader)         # expect: bad
yaml.load(text, Loader=chosen_loader)               # expect: bad

Next comes a gate written with Python’s standard ast module. An AST (abstract syntax tree) is the structure Python builds from source code before running it, and walking it lets you look at calls instead of searching for text. The gate treats the safe loaders as an allow-list: any yaml.load call whose Loader is not on the list is a finding, and so is any call to full_load or unsafe_load. It follows aliases such as import yaml as y and from yaml import load as yload.

# yamlgate.py - fail the build when YAML is loaded with a loader that can build objects
import ast
import sys
from pathlib import Path

SAFE_LOADERS = {"SafeLoader", "CSafeLoader", "BaseLoader", "CBaseLoader", "StrictLoader"}
OBJECT_BUILDERS = {"unsafe_load", "unsafe_load_all", "full_load", "full_load_all"}
GENERIC = {"load", "load_all"}


def loader_name(expr, modules, imported):
    """Name of the Loader expression, or None when we cannot tell."""
    if (isinstance(expr, ast.Attribute) and isinstance(expr.value, ast.Name)
            and expr.value.id in modules):
        return expr.attr
    if isinstance(expr, ast.Name):
        return imported.get(expr.id, expr.id)
    return None


def check_source(source):
    """Return sorted (line, reason) pairs for every risky YAML call in `source`."""
    tree = ast.parse(source)
    modules, functions, imported = set(), {}, {}
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            modules.update(a.asname or a.name for a in node.names if a.name == "yaml")
        elif isinstance(node, ast.ImportFrom) and node.module == "yaml":
            for alias in node.names:
                local = alias.asname or alias.name
                if alias.name in OBJECT_BUILDERS | GENERIC:
                    functions[local] = alias.name
                else:
                    imported[local] = alias.name
    found = []
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        func = node.func
        if (isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name)
                and func.value.id in modules):
            name = func.attr
        elif isinstance(func, ast.Name) and func.id in functions:
            name = functions[func.id]
        else:
            continue
        if name in OBJECT_BUILDERS:
            found.append((node.lineno, f"yaml.{name} can build Python objects"))
        elif name in GENERIC:
            arg = next((kw.value for kw in node.keywords if kw.arg == "Loader"), None)
            if arg is None and len(node.args) > 1:
                arg = node.args[1]
            used = loader_name(arg, modules, imported) if arg is not None else None
            if used not in SAFE_LOADERS:
                found.append((node.lineno, f"yaml.{name} with Loader={used}"))
    return sorted(found)


def main(paths):
    bad = 0
    for root in paths:
        root = Path(root)
        for path in sorted(root.rglob("*.py")) if root.is_dir() else [root]:
            for line, why in check_source(path.read_text(encoding="utf-8")):
                print(f"{path}:{line}: {why}")
                bad += 1
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:] or ["."]))

And a script that runs Ruff’s S506 and the gate over the sample file and prints both verdicts side by side:

# step11_gate.py - what Ruff's S506 rule and our own gate each flag
import re
import subprocess
import sys
from pathlib import Path

from yamlgate import check_source


def ruff_lines(path):
    result = subprocess.run(
        [sys.executable, "-m", "ruff", "check", "--select", "S506", "--isolated",
         "--no-cache", "--output-format", "concise", path],
        capture_output=True, text=True)
    return {int(m.group(1)) for m in re.finditer(r":(\d+):\d+: S506", result.stdout)}


source = Path("gate_samples.py").read_text(encoding="utf-8")
ruff = ruff_lines("gate_samples.py")
ours = {line for line, _ in check_source(source)}

print(f"{'line':<5}{'code':<45}{'expect':<8}{'Ruff':<6}gate")
bad_lines, ok_lines = [], []
for number, row in enumerate(source.splitlines(), 1):
    if "# expect:" not in row:
        continue
    code, expect = (part.strip() for part in row.split("# expect:"))
    (bad_lines if expect == "bad" else ok_lines).append(number)
    print(f"{number:<5}{code:<45}{expect:<8}"
          f"{'FLAG' if number in ruff else '-':<6}{'FLAG' if number in ours else '-'}")

for label, flagged in (("Ruff S506", ruff), ("our gate", ours)):
    missed = [n for n in bad_lines if n not in flagged]
    false_alarms = [n for n in ok_lines if n in flagged]
    print(f"{label:<10}: missed {len(missed)} of {len(bad_lines)} bad lines, "
          f"{len(false_alarms)} false alarms on {len(ok_lines)} ok lines")

own = ruff_lines("safeyaml.py")
print("Ruff S506 on safeyaml.py:", f"flags line(s) {sorted(own)}" if own else "clean")
print("our gate on safeyaml.py :", check_source(Path("safeyaml.py").read_text(encoding="utf-8")) or "clean")

Run python step11_gate.py:

line code                                         expect  Ruff  gate
12   yaml.safe_load(text)                         ok      -     -
13   yaml.load(text, Loader=yaml.SafeLoader)      ok      -     -
14   load(text, Loader=SafeLoader)                ok      -     -
15   yaml.load(text, Loader=StrictLoader)         ok      FLAG  -
16   yaml.load(text)                              bad     FLAG  FLAG
17   yaml.load(text, Loader=yaml.Loader)          bad     FLAG  FLAG
18   yaml.load(text, yaml.FullLoader)             bad     FLAG  FLAG
19   yaml.full_load(text)                         bad     -     FLAG
20   yaml.unsafe_load(text)                       bad     -     FLAG
21   y.load(text, Loader=y.UnsafeLoader)          bad     FLAG  FLAG
22   load(text, Loader=Loader)                    bad     FLAG  FLAG
23   yload(text, Loader=FL)                       bad     FLAG  FLAG
24   yaml.load_all(text, Loader=yaml.FullLoader)  bad     -     FLAG
25   yaml.load(text, Loader=chosen_loader)        bad     FLAG  FLAG
Ruff S506 : missed 3 of 10 bad lines, 1 false alarms on 4 ok lines
our gate  : missed 0 of 10 bad lines, 0 false alarms on 4 ok lines
Ruff S506 on safeyaml.py: clean
our gate on safeyaml.py : clean

Both tools flagged the dangerous yaml.load shapes, including the aliased ones. The differences are the useful part. On Ruff 0.17.3, S506 alone missed three of the ten bad lines: the calls to full_load, unsafe_load and load_all. That fits its description, which says it “Checks for uses of the yaml.load function.” It also flagged Loader=StrictLoader, a false alarm, because a linter cannot tell that a class imported from another module inherits from SafeLoader. That is why safeyaml.py carries a # noqa: S506 on its single yaml.load line, and why the last two lines show Ruff and the gate both clean on our own code. The gate flagged all ten bad lines and none of the four safe ones, because it asks a different question: is the loader on my list of known-safe names? A variable such as chosen_loader is flagged, since the gate cannot prove what it holds.

To use it in CI, run both commands against your source folder and let a non-zero exit code fail the job:

python -m ruff check --select S506 src
python yamlgate.py src

yamlgate.py exits with 1 when it prints any finding. Its allow-list matches loader names, not classes, so a class you name SafeLoader yourself would pass. Keep the list short and review every change to it.

Step 12: Confirm the whole thing works end to end

Run these four commands from your lab folder. Together they answer the questions that matter:

python -m pytest -q | tail -n 1
python mutations.py | tail -n 1
python yamlgate.py safeyaml.py manifest.py; echo "gate exit code: $?"
python -m ruff check --select S506 --isolated safeyaml.py manifest.py

You should see all 49 tests passing, all ten planted bugs caught, no gate findings with exit code 0, and Ruff reporting that all checks passed:

49 passed in 0.72s
10 of 10 planted bugs were caught
gate exit code: 0
All checks passed!

If all four lines match, your program can read a stranger’s YAML file without running anything the file names, without being flattened by a bomb, without trusting a duplicated key and without turning NO into False.

Common mistakes and gotchas

  • Other loader names. yaml.load(text, Loader=yaml.FullLoader), yaml.full_load and yaml.unsafe_load are not the fix. Use yaml.safe_load, or load_text from this tutorial for files you do not control.
  • Safe is not bounded. safe_load stops code execution, not resource use. Cap the file size before parsing, count nodes after aliases, and be careful what you do with an aliased structure afterwards.
  • Quote what must stay text. In your own YAML write "NO", "3.10" and "0755" with quotes whenever the value is a name, a version or an identifier.
  • What StrictLoader does not support. It has no merge keys, sets or timestamps, and it accepts exactly one document. For a trusted CI file that needs merge keys, use safe_load there and keep the strict loader for outside input.
  • Speed. The strict loader is built on the pure-Python SafeLoader. I did not test whether the same checks can hook into the libyaml-based CSafeLoader, so do not assume it.
  • A schema only checks what you declare. Keep extra="forbid", or an unknown key will pass through unnoticed.
  • Other languages have the same feature. The YAML specification says that processors “typically provide some form of local tags that map directly to the language’s native data structures” and gives !ruby/object:Set as an example. Check what the parser you use does with such tags.

Where to go next

Validate every file format at the point where it enters your program: the schema step works the same way for JSON and TOML. If you need YAML 1.2 semantics, try ruamel.yaml and run the Step 8 comparison on your own sample files. Run mutations.py after every change to the loader, and add a test for any new tag you allow. For the sibling problems, read our tutorials on loading pickle and model files safely, parsing untrusted XML and stopping decompression bombs.

Tags:

Application SecurityInsecure DeserializationPydanticPythonPyYAMLYAML

Share

A dark green horse stable with four white cross-braced stall doors, one open with a horse looking out
Previous Post

Trail of Bits’ Codex Backporting Turns Stable-Kernel Security Into a Review-Hours Problem, but 143 of Its 206 Deliveries Look Like Plain Cherry-Picks and the Bug Behind the OpenAI Hack Had No CVE

An open trapdoor in a wooden plank floor, with the hatch lid propped to one side above a dark square opening
Next Post

Google Bans Android TV Box Maker Videostrong Over a Backdoor on 1.3 Million Devices, but the Takedown Holds Domains and Leaves the Implant on Each Box

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
06 Nov
How to Secure a WebSocket Endpoint in FastAPI and Stop Cross-Site Hijacking, Leaked Tokens, Floods, and Slow Readers
06 Nov
OpenAI’s Astra Report Turns Silent Safety-Task Underperformance Into a Matched-Pair Gap, but the Ethical-Doubt Result’s Bar Runs From a 4-Point Gap to a 3.6-Point Reversal, and the Launch Card Quoted AISI as Seeing Very Few Refusals
Trending
November 6, 2026
How to Secure a WebSocket Endpoint in FastAPI and Stop Cross-Site Hijacking, Leaked Tokens, Floods, and Slow Readers
November 6, 2026
OpenAI’s Astra Report Turns Silent Safety-Task Underperformance Into a Matched-Pair Gap, but the Ethical-Doubt Result’s Bar Runs From a 4-Point Gap to a 3.6-Point Reversal, and the Launch Card Quoted AISI as Seeing Very Few Refusals
November 6, 2026
OpenAI Releases GPT-6 Cyber, Rated Critical, to Approved Customers: the Post’s 99.93% Scope Score Is Measured Against the Older Cyber Model, the System Card Puts Its Base Model at 0% Honeypot Exploitation, and One Outside Benchmark Has It Solving Fewer Challenges
November 5, 2026
How to Spread Requests Across Servers in Python With the Power of Two Choices and Avoid the Herd Effect of Least-Loaded Routing
November 5, 2026
Microsoft’s PDD Kit Turns Code Review Into Intent Review, but Verus Accepts a Login That Answers Every Attempt With a 500 Under the Contract the Post Prints
November 5, 2026
OpenSSH Patches a Password Bypass on musl Systems Such as Alpine, Where a 257 to 1,024 Byte Password Logs In to Password-Disabled Accounts, and musl’s Maintainer Says the Bug Is Not in musl

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026