Warm-up · Activity 1 of 7
Warm-up from the previous lesson: the input is bound to a placeholder. What does this print?
import sqlite3
con = sqlite3.connect(":memory:")
print(con.execute("SELECT ?", ("x' OR '1'='1",)).fetchone())// A5.4 · ~35 min · Advanced
After this lesson you can turn text into data with ast.literal_eval or json instead of eval, explain why unpickling untrusted bytes can run code, and start other programs with subprocess.run and an argument list instead of a shell.
You will be able to
Warm-up · Activity 1 of 7
import sqlite3
con = sqlite3.connect(":memory:")
print(con.execute("SELECT ?", ("x' OR '1'='1",)).fetchone())Predict · Activity 2 of 7
text = "print('I ran') or 42" # typed by a user
print(eval(text)) # the mistake: eval runs any expressionPractice · Activity 3 of 7
import ast
text = "{'a': [1, 2], 'b': None}"
data = ast.____(text)
print(data["a"][1], data["b"])Practice · Activity 4 of 7
import pickle
class Loud:
def __reduce__(self):
return (print, ("unpickling ran print",))
data = pickle.dumps(Loud())
result = pickle.loads(data)
print(result)Practice · Activity 5 of 7
Brain teaser · Activity 6 of 7
import ast
for text in ["-5", "(1, 'a')", "2 + 3", "{1, 2}"]:
try:
print(ast.literal_eval(text), end=" ")
except ValueError:
print("rejected", end=" ")Apply · Activity 7 of 7
Check your work against this list
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
The program reads a settings literal with ast.literal_eval and shows that a call hidden in the text is rejected. It then round-trips data through json. Last, it unpickles an Alarm object whose __reduce__ names print: the message appears during pickle.loads, before your code sees any object. A crafted pickle can name a harmful function in the same way; here it only prints.
main.py
import ast
import json
import pickle
class Alarm:
"""Stands in for a crafted object: unpickling it calls print."""
def __reduce__(self):
return (print, ("!! code ran while unpickling",))
# 1. Text holding a Python literal: ast.literal_eval, never eval.
settings = ast.literal_eval("{'size': (800, 600), 'tags': ['a', 'b'], 'debug': False}")
print(settings["size"][0] * settings["size"][1], settings["tags"])
try:
ast.literal_eval("__import__('os').getcwd()")
except ValueError as err:
print("rejected:", str(err).split(":")[0])
# 2. Data from outside the program: json can only build data.
text = json.dumps({"user": "ada", "scores": [3, 5]})
print(text)
print(json.loads(text)["scores"])
# 3. pickle can call functions while loading: only for data you trust.
blob = pickle.dumps(Alarm())
pickle.loads(blob)
Run it with
python main.pyOutput
480000 ['a', 'b']
rejected: malformed node or string on line 1
{"user": "ada", "scores": [3, 5]}
[3, 5]
!! code ran while unpicklingTab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Exercise 1 of 3
parse_settings(text) uses eval, so text such as "{'n': len('abc')}" runs code. Rewrite it with ast.literal_eval. Raise ValueError when the text is not a literal, and also when the result is not a dict whose keys are all strings.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
import ast, then value = ast.literal_eval(text). It already raises ValueError for calls and names.
After parsing, check isinstance(value, dict) and all(isinstance(key, str) for key in value).
Raise ValueError yourself when either check fails.
One way to solve it. Yours can look different and still pass the checks.
import ast
def parse_settings(text: str) -> dict[str, object]:
"""Turn text such as "{'width': 80, 'tags': ['a']}" into a dict."""
value = ast.literal_eval(text) # ValueError for anything that is not a literal
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
raise ValueError("settings must be a dict with str keys")
return value
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
def parse_settings(text: str) -> dict[str, object]:
"""Turn text such as "{'width': 80, 'tags': ['a']}" into a dict."""
# eval runs any expression in text. Use ast.literal_eval, and raise
# ValueError unless the result is a dict with only str keys.
return eval(text)
test_main.py
from main import parse_settings
def rejects(text):
try:
parse_settings(text)
except ValueError:
return True
return False
def test_literal_dict():
"""A dict of literals is parsed"""
got = parse_settings("{'width': 80, 'tags': ['a', 'b'], 'theme': None}")
assert got == {"width": 80, "tags": ["a", "b"], "theme": None}, f"parse_settings gave {got!r}"
def test_rejects_call():
"""A function call in the text is refused, not run"""
assert rejects("{'n': len('abc')}"), "{'n': len('abc')} was accepted: eval ran len(); use ast.literal_eval"
def test_rejects_name():
"""A variable name in the text is refused"""
assert rejects("{'n': width}"), "a name in the text was accepted or raised the wrong error"
def test_rejects_non_dict():
"""A list is not settings"""
assert rejects("[1, 2]"), "[1, 2] was accepted; raise ValueError unless the result is a dict"
def test_rejects_non_str_key():
"""Keys must be strings"""
assert rejects("{1: 'a'}"), "{1: 'a'} was accepted; every key must be a str"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 2 of 3
dump_scores and load_scores exchange scores with other programs as hex-encoded pickles, so load_scores would run whatever a crafted pickle names. Switch both to json: dump_scores returns JSON text, and load_scores raises ValueError unless the text is a JSON object whose values are all integers (not booleans).
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
json.dumps(scores) and json.loads(text). json.JSONDecodeError is a subclass of ValueError, so bad text already raises it.
A hex string is not valid JSON, so a pickle is refused before anything in it could run.
Check isinstance(data, dict), then every value with isinstance(v, int) and not isinstance(v, bool).
One way to solve it. Yours can look different and still pass the checks.
import json
def dump_scores(scores: dict[str, int]) -> str:
"""Save scores as text that another program can read back."""
return json.dumps(scores)
def load_scores(text: str) -> dict[str, int]:
"""Read scores saved by dump_scores; raise ValueError for anything else."""
data = json.loads(text) # json.JSONDecodeError is a ValueError
if not isinstance(data, dict):
raise ValueError("scores must be a JSON object")
for name, points in data.items():
if not isinstance(points, int) or isinstance(points, bool):
raise ValueError(f"score for {name!r} is not an integer")
return data
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import pickle
def dump_scores(scores: dict[str, int]) -> str:
"""Save scores as text that another program can read back."""
return pickle.dumps(scores).hex()
def load_scores(text: str) -> dict[str, int]:
"""Read scores saved by dump_scores; raise ValueError for anything else."""
return pickle.loads(bytes.fromhex(text))
test_main.py
import json
import pickle
from main import dump_scores, load_scores
class Alarm:
def __reduce__(self):
return (print, ("alarm",))
def rejects(text):
try:
load_scores(text)
except ValueError:
return True
return False
def test_round_trip():
"""Scores survive dump and load"""
scores = {"ada": 3, "bob": 5}
got = load_scores(dump_scores(scores))
assert got == scores, f"load_scores(dump_scores(...)) gave {got!r}"
def test_readable_json():
"""dump_scores writes JSON that any program can read"""
text = dump_scores({"ada": 3})
try:
got = json.loads(text)
except ValueError:
got = None
assert got == {"ada": 3}, f"dump_scores wrote {text[:40]!r}, which is not JSON"
def test_refuses_pickle():
"""A pickle is refused with ValueError, and nothing in it runs"""
blob = pickle.dumps(Alarm()).hex()
assert rejects(blob), "load_scores accepted a pickle: it could run code while loading"
def test_checks_types():
"""A score that is not an integer is refused"""
assert rejects('{"ada": "three"}'), 'load_scores accepted {"ada": "three"}'
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 3 of 3
child_args(words) starts a second Python process and returns the arguments it received. The starter builds a shell command, so "two words" splits in two, $HOME is expanded and an apostrophe breaks the command. Pass a list to subprocess.run instead, with check=True. Run the tests on your own computer: the browser cannot start processes.
This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.
The list is [sys.executable, "-c", CHILD, *words]: each word becomes exactly one argument.
Leave out shell=True; subprocess does not use a shell unless you ask for one.
Keep capture_output=True and text=True so result.stdout is a str for json.loads.
One way to solve it. Yours can look different and still pass the checks.
import json
import subprocess
import sys
CHILD = "import json, sys; print(json.dumps(sys.argv[1:]))"
def child_args(words: list[str]) -> list[str]:
"""Run CHILD in a new Python process with words as its arguments; return what it received."""
result = subprocess.run(
[sys.executable, "-c", CHILD, *words],
capture_output=True,
text=True,
check=True,
)
return json.loads(result.stdout)
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import json
import subprocess
import sys
CHILD = "import json, sys; print(json.dumps(sys.argv[1:]))"
def child_args(words: list[str]) -> list[str]:
"""Run CHILD in a new Python process with words as its arguments; return what it received."""
# A shell splits, expands and interprets the words. Pass a list instead.
command = f'"{sys.executable}" -c "{CHILD}" ' + " ".join(words)
result = subprocess.run(command, shell=True, capture_output=True, text=True)
return json.loads(result.stdout)
test_main.py
from main import child_args
def test_simple():
"""Two plain words arrive as two arguments"""
got = child_args(["a", "b"])
assert got == ["a", "b"], f"the child received {got!r}"
def test_spaces():
"""A word with a space stays one argument"""
got = child_args(["two words"])
assert got == ["two words"], f"the child received {got!r}: no shell, pass a list"
def test_special_characters():
"""$HOME and an apostrophe arrive unchanged"""
got = child_args(["$HOME", "it's"])
assert got == ["$HOME", "it's"], f"the child received {got!r}: a shell expanded or broke them"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyimport ast
size = 3
config = ast.literal_eval("[1, 2, size]")
What Python prints
ValueError: malformed node or string on line 1: Name(id='size', ctx=Load())Why, and the fix
literal_eval has no variables: it cannot look up size. That is the point, so do not reach for eval. Parse the literal part and combine it in code, for example ast.literal_eval("[1, 2]") + [size].
import json
tags = {"python", "security"}
print(json.dumps({"tags": tags}))
What Python prints
TypeError: Object of type set is not JSON serializableWhy, and the fix
JSON has objects, arrays, strings, numbers, true, false and null, but no sets, tuples or custom classes. Convert before dumping, for example sorted(tags), and convert back after loading. That limit is the reason json is safe: loading it can only build plain data.
import subprocess
import sys
count = 3
subprocess.run([sys.executable, "-c", "import sys; print(sys.argv)", count])
What Python prints
TypeError: expected str, bytes or os.PathLike object, not intWhy, and the fix
Every item of the argument list becomes a command-line argument, and those are always text. Convert first: str(count). Building one command string with f-strings instead would reopen the shell problems.
Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.