Most test suites for input handling cover the happy path extremely well and the dangerous path barely at all. Normal names, normal emails, normal search terms, all pass fine. The inputs that actually break an escaping function are the ones nobody typed in manually while writing the test, because they don't look like normal data. Here's a practical way to build a fixture list that actually exercises those boundaries.
Start from real syntax, not imagination
The mistake teams make when they do try to test malicious input is inventing generic "bad string" examples that don't map to any real parser behavior. A useful fixture list starts from the actual special characters each context cares about, not a vague sense of "weird" input.
For HTML: <, >, &, ", ', and the string javascript: as a URL scheme. For SQL: single quotes, backslashes, and statement-terminating semicolons, plus comment sequences like -- and /* */ that some engines interpret. For shell: spaces, semicolons, backticks, dollar signs, pipes, and a leading hyphen that could be mistaken for a command flag.
HOSTILE_STRINGS = [
"O'Brien", # apostrophe in a name field
'<script>alert(1)</script>', # raw script tag
"'; DROP TABLE users; --", # classic SQL comment truncation
"$(rm -rf /)", # shell command substitution
"file`whoami`.txt", # backtick in a filename
"-rf important-data", # leading-dash flag confusion
"test@example.com\r\nBcc: x", # header injection via newline
]
None of these are exotic. They're the kind of thing a real user types by accident, an apostrophe in a last name, a filename with a stray character, and they're exactly what a generic security scanner tends to miss because scanners look for known attack signatures rather than testing your actual code paths.
Photo by Aaron Burden on Unsplash
Cover the "plausible" half, not just the "hostile" half
A fixture list of pure attack strings tests whether your escaping function survives an attack. It doesn't test whether your escaping function breaks legitimate data that happens to contain special characters. Names with apostrophes, addresses with ampersands, search terms with quotation marks, these are real inputs from real users, and an escaping function that's too aggressive will corrupt them just as reliably as a weak one will let an attack through.
Include names like O'Brien and D'Angelo, company names with & in them, and addresses with # for apartment numbers. If your escaping logic mangles these into garbage on output, that's a legitimate bug even though nothing malicious happened. The Wikipedia entry on cross-site scripting is worth skimming for a sense of how varied real payloads get, which is useful context even if you're testing defensive code rather than building an attack.
Wire the fixture list into every boundary, not one function
The value of this list comes from running it through every point where a string crosses from one context into another: the HTML template, the database query, any shell command, any file path construction, any email header. A fixture list that only tests your escapeHtml function in isolation misses the actual failure mode, which is usually a boundary somebody forgot existed.
def test_all_boundaries_handle_hostile_input():
for payload in HOSTILE_STRINGS:
# Render through the real template path, not a mock
rendered = render_user_profile_page(name=payload)
assert "<script>" not in rendered
# Run through the real query path
result = search_users_by_name(payload)
assert result is not None # no exception, no silent failure
# If the payload ever reaches a shell-invoking function, confirm it doesn't
if uses_filename_in_shell_command(payload):
assert shell_call_uses_argument_list(payload)
The goal is coverage of every boundary your application actually has, which means this list needs to live somewhere the whole team references, not buried inside one module's test file.
Extend the list to file names and upload paths
Text fields aren't the only place hostile-but-plausible input matters. File uploads deserve their own set of fixtures: filenames with spaces, semicolons, or backticks, filenames that start with a hyphen, filenames using path traversal sequences like ../../etc/passwd, and filenames with Unicode look-alike characters that can confuse extension checks or display rendering.
HOSTILE_FILENAMES = [
"invoice (final) v2.pdf", # spaces and parentheses, both legitimate
"../../etc/passwd", # path traversal attempt
"-rf backup.tar.gz", # leading dash, flag confusion
"report`whoami`.csv", # backtick, shell metacharacter
"résumé.pdf", # legitimate unicode, not an attack
]
Run these through your upload handler, your file storage path construction, and anything that later shells out to process the uploaded file, image conversion, PDF text extraction, virus scanning. This is exactly the kind of boundary that tends to get missed because the "input" here doesn't look like a typical form field, it's metadata attached to a file, and teams often only think to sanitize the file's contents, not its name.
Sharing the list across a team instead of keeping it in one head
A fixture list only pays off if the people writing new features actually use it. Keep it somewhere visible, a shared test utilities module, a documented file in the repo's testing guide, not buried in one engineer's personal test file. When someone builds a new feature that accepts user input and needs to write tests for it, importing this shared list should be the obvious, low-friction default rather than something they have to remember exists.
Some teams go further and wire the fixture list into a shared pytest fixture or equivalent test helper, so any new test that accepts a string parameter can request the hostile-input set with one line, rather than reimplementing a smaller, ad hoc version of it every time.
Re-run it after every dependency upgrade
Escaping and sanitization behavior changes quietly between library versions more often than most teams expect. A templating engine might tighten its auto-escaping rules, a sanitization library might change which tags it strips by default, and none of that shows up unless a test explicitly checks for it. Running the hostile-but-plausible fixture list as part of your regular test suite, not as a one-off audit, catches these regressions before a user does.

Photo by Monstera Production on Pexels
The Python documentation and the OWASP cheat sheet series are both good sources to check whenever you add a new library to this test path, since both track known edge cases that a hand-built fixture list can miss on the first pass.
Keep the list growing, not static
Every time a real bug slips through in production, whether it's an injection attempt or just a legitimate input that got mangled, add the exact string that caused it to this fixture list. Over time this turns a generic list into a living record of your application's specific failure history, which is far more useful than a textbook list of "common attack strings" that doesn't reflect what your codebase actually does wrong.
Keep it small enough that people actually run it
There's a temptation to keep expanding this list until it covers every attack pattern anyone's ever documented, at which point it becomes so large that running it slows down the test suite noticeably and people start skipping it locally. Keep the core list focused, roughly ten to fifteen entries that cover the major special-character categories for each context your application actually has, and let it grow only when a real bug adds a genuinely new case rather than a variation of one already covered. A fixture list that runs in under a second and stays in every developer's default test run catches far more in practice than a comprehensive one that only runs in CI once a day.
Wrapping up
A hostile-but-plausible fixture list is cheap to build and expensive to skip. It costs an afternoon to assemble the first version and a few minutes to extend whenever something new breaks. If your team wants a second set of eyes on where these boundaries actually sit in a specific codebase, that's a review this development team does regularly, and it pairs well with the broader patterns covered in 137Foundry's guide to escaping across HTML, SQL, and shell contexts.
Top comments (0)