Security Fundamentals · lesson 5 of 8
Beginner
4Intermediate
4Also part of Security Fundamentals
12- + Permission Bits, Ownership, and Why passwd Needs setuid from Linux
- + Database Security from SQL
- + ACL: a User Restricted to One Key Pattern and One Command from Redis
- + Kafka Security: SASL Authentication and a Real ACL Denial from Kafka
- + IAM and Least Privilege from Cloud Interviews
- + Testing Bearer Tokens, Basic Auth, and 401 vs 403 from API Testing
- + FastAPI Authentication: Password Hashing, OAuth2 and JWT from FastAPI
- + Docker Security from Docker
- + Data Security and PII: Hashing, Masking, and Tokenization from Data Engineering
- + Hashes and HMACs: What Each One Actually Proves from Cryptography
- + Broken Access Control and IDOR: Fix It in Code, Pin It with a Test from Web Security (OWASP Top 10, fix-side)
- + MCP Authorization: OAuth 2.1, CIMD and a Real Identity Provider from MCP (Model Context Protocol)
Fail Closed: When an Exception Grants Access
Watch an authorization check grant access when its policy lookup raises (OWASP A10:2025), catch it with a failing pytest, and fix it to deny on error.
By SkillByExample · Published
An access check has three outcomes, not two: allow, deny, and couldn’t decide. The third one is where
systems get breached. The policy service times out, a file is missing, a function returns None. If the
code treats “couldn’t decide” as “allow”, it fails open. OWASP added this in 2025 as A10, Mishandling of
Exceptional Conditions. This lesson shows the bug, catches it with a test, and fixes it to fail closed.
Run with Python 3.12.15 and pytest 9.1.1 (pip install pytest in python:3.12-slim).
An authorization check that fails open
The policy lives in a separate service. To avoid locking everyone out during an outage, a developer made the check permissive on error.
# authz.py
class PolicyServiceDown(Exception):
pass
POLICIES = {("alice", "read"), ("alice", "delete")}
SERVICE_UP = True
def lookup(user, action):
if not SERVICE_UP:
raise PolicyServiceDown("policy service timed out")
return (user, action) in POLICIES
def is_allowed(user, action):
try:
return lookup(user, action)
except Exception:
return True # "don't lock everyone out when the policy service is down"
# demo.py
import authz
for up in (True, False):
authz.SERVICE_UP = up
allowed = authz.is_allowed("mallory", "delete")
print(f"policy service up={up}: mallory delete allowed={allowed}")
$ python demo.py
policy service up=True: mallory delete allowed=False
policy service up=False: mallory delete allowed=True
mallory has no permissions at all. While the service is up she’s correctly refused. When it goes down,
the except branch returns True and she can delete. Her permissions didn’t change, the error did. An
attacker who can make the lookup fail, by flooding the policy service, sending input that crashes the
parser, or waiting for a deploy, gets every permission in the system. The comment explains the intent,
and the intent is exactly the vulnerability.
A test that pins the failure mode
Happy-path tests won’t find this, because the bug only appears when a dependency breaks. So write a test
that breaks it on purpose. monkeypatch simulates the outage without a real service.
# test_authz.py
import pytest
import authz
@pytest.fixture
def outage(monkeypatch):
monkeypatch.setattr(authz, "SERVICE_UP", False)
def test_alice_can_delete():
assert authz.is_allowed("alice", "delete") is True
def test_unknown_user_denied():
assert authz.is_allowed("mallory", "delete") is False
def test_outage_denies(outage):
allowed = authz.is_allowed("mallory", "delete")
assert allowed is False, f"allowed={allowed} after the policy lookup raised"
$ pytest -q
..F [100%]
=================================== FAILURES ===================================
______________________________ test_outage_denies ______________________________
outage = None
def test_outage_denies(outage):
allowed = authz.is_allowed("mallory", "delete")
> assert allowed is False, f"allowed={allowed} after the policy lookup raised"
E AssertionError: allowed=True after the policy lookup raised
E assert True is False
test_authz.py:17: AssertionError
=========================== short test summary info ============================
FAILED test_authz.py::test_outage_denies - AssertionError: allowed=True after...
1 failed, 2 passed in 0.09s
The two ordinary tests pass, which is why this kind of bug survives code review. The outage test fails at
line 17 with the exact symptom: allowed=True after the lookup raised. The assertion uses is False
rather than not allowed on purpose. A check that returns None or an error string must not count as a
pass.
The fix: deny on exception, grant only on True
Two changes. The handler denies and logs instead of granting, and the success path grants only on an
explicit True.
# authz.py
import logging
log = logging.getLogger("authz")
class PolicyServiceDown(Exception):
pass
POLICIES = {("alice", "read"), ("alice", "delete")}
SERVICE_UP = True
def lookup(user, action):
if not SERVICE_UP:
raise PolicyServiceDown("policy service timed out")
return (user, action) in POLICIES
def is_allowed(user, action):
try:
return lookup(user, action) is True # only an explicit True grants
except Exception as exc:
log.warning("DENY %s %s: policy lookup failed: %r", user, action, exc)
return False
$ pytest -q
... [100%]
3 passed in 0.09s
$ python demo.py
policy service up=True: mallory delete allowed=False
DENY mallory delete: policy lookup failed: PolicyServiceDown('policy service timed out')
policy service up=False: mallory delete allowed=False
The same test file now passes, and the demo refuses mallory in both states. Failing closed has a real
cost: during the outage, alice is refused too. That’s why the DENY line matters. An outage that locks
users out gets noticed and fixed, while one that quietly lets everyone in can run for months. If
availability during outages really matters, cache the last known good policy with a short expiry. Never
fall back to “allow”. Catching the broad Exception is deliberate here. Any failure, including a bug in
lookup itself, should end in a denial.
The quiet version: None is falsy
No exception is needed to fail open. Here a helper returns None when it can’t read the ban list, and
the caller’s truthiness check reads “unknown” as “not banned”.
# denylist.py
def is_banned(user):
"""True if banned, False if not, None if the ban list could not be read."""
try:
with open("/etc/app/banned.txt") as f:
return user in f.read().split()
except OSError:
return None
def can_login_bad(user):
if is_banned(user): # None is falsy, so "unknown" reads as "not banned"
return False
return True
def can_login_good(user):
return is_banned(user) is False # only a definite "not banned" lets you in
print("ban list readable? no (file missing)")
print("bad : mallory can log in:", can_login_bad("mallory"))
print("good: mallory can log in:", can_login_good("mallory"))
$ python denylist.py
ban list readable? no (file missing)
bad : mallory can log in: True
good: mallory can log in: False
The helper did its job. It returned a distinct value for “couldn’t tell” and documented it. The caller threw
that information away with if is_banned(user):. A denylist is an allow-by-default design to begin with,
so any gap in it fails open. Where you can, invert it into an allowlist. Where you can’t, compare with
is False so only a definite answer lets anyone through.
Secure defaults: a missing setting must not switch protection off
Configuration fails open the same way. Compare two ways of reading an AUTH_REQUIRED flag across the values
an operator might really set.
# settings.py
def auth_required_bad(env):
return env.get("AUTH_REQUIRED", "false") == "true" # off unless spelled exactly
def auth_required_good(env):
return env.get("AUTH_REQUIRED", "true") != "false" # on unless spelled exactly
cases = [{}, {"AUTH_REQUIRED": "true"}, {"AUTH_REQUIRED": "True"},
{"AUTH_REQUIRED": "ture"}, {"AUTH_REQUIRED": "false"}]
print(f"{'AUTH_REQUIRED':<16}{'bad':<7}good")
for env in cases:
value = repr(env.get("AUTH_REQUIRED", "<unset>"))
print(f"{value:<16}{auth_required_bad(env)!s:<7}{auth_required_good(env)}")
$ python settings.py
AUTH_REQUIRED bad good
'<unset>' False True
'true' True True
'True' False True
'ture' False True
'false' False False
The bad version turns authentication off for a missing variable, a capitalised True, and a typo. Only
one exact spelling turns it on. The good version inverts that: protection stays on unless someone types
exactly false. Both are one line, and only the default differs. Where the safe value goes decides which
way every mistake falls. The second practice task goes one step further and refuses to start on a value it
doesn’t recognise.
Practice
- Pin the quiet failure in a test. Add tests where
lookupreturnsNone, and where it returns the string"error: timeout", without raising. Run them against both versions ofauthz.py.
Solution
# test_none.py
import authz
def test_none_from_backend_is_denied(monkeypatch):
monkeypatch.setattr(authz, "lookup", lambda user, action: None)
assert authz.is_allowed("alice", "delete") is False
def test_truthy_string_is_not_a_grant(monkeypatch):
monkeypatch.setattr(authz, "lookup", lambda user, action: "error: timeout")
assert authz.is_allowed("alice", "delete") is False
Against the fixed authz.py:
$ pytest -q test_none.py
.. [100%]
2 passed in 0.08s
Against the original, fail-open authz.py, both fail:
$ pytest -q test_none.py
FF [100%]
=================================== FAILURES ===================================
_______________________ test_none_from_backend_is_denied _______________________
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f4c22c31370>
def test_none_from_backend_is_denied(monkeypatch):
monkeypatch.setattr(authz, "lookup", lambda user, action: None)
> assert authz.is_allowed("alice", "delete") is False
E AssertionError: assert None is False
E + where None = <function is_allowed at 0x7f4c22ba1620>('alice', 'delete')
E + where <function is_allowed at 0x7f4c22ba1620> = authz.is_allowed
test_none.py:6: AssertionError
______________________ test_truthy_string_is_not_a_grant _______________________
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f4c22c32960>
def test_truthy_string_is_not_a_grant(monkeypatch):
monkeypatch.setattr(authz, "lookup", lambda user, action: "error: timeout")
> assert authz.is_allowed("alice", "delete") is False
E AssertionError: assert 'error: timeout' is False
E + where 'error: timeout' = <function is_allowed at 0x7f4c22ba1620>('alice', 'delete')
E + where <function is_allowed at 0x7f4c22ba1620> = authz.is_allowed
test_none.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_none.py::test_none_from_backend_is_denied - AssertionError: asser...
FAILED test_none.py::test_truthy_string_is_not_a_grant - AssertionError: asse...
2 failed in 0.08s
The original returns whatever lookup returned, so an error string is truthy and would pass any
if is_allowed(...): check. The fixed version’s is True turns both into denials.
- Refuse to start on a bad flag. Write
parse_flag(env, name, default=True)that acceptstrue/1/yesandfalse/0/noin any case and with surrounding spaces, and raises on anything else.
Solution
# settings_strict.py
def parse_flag(env, name, default=True):
raw = env.get(name)
if raw is None:
return default
value = raw.strip().lower()
if value in ("true", "1", "yes"):
return True
if value in ("false", "0", "no"):
return False
raise ValueError(f"{name}={raw!r} is not a boolean; refusing to start")
for raw in [None, " False ", "ture"]:
env = {} if raw is None else {"AUTH_REQUIRED": raw}
try:
print(repr(raw), "->", parse_flag(env, "AUTH_REQUIRED"))
except ValueError as e:
print(repr(raw), "->", e)
$ python settings_strict.py
None -> True
' False ' -> False
'ture' -> AUTH_REQUIRED='ture' is not a boolean; refusing to start
A crash at startup is failing closed for configuration. The typo is found in the deploy log within seconds, instead of in an incident report months later.
- Load a policy file safely. Read
[{"user": ..., "action": ...}]from a JSON file. If the file is missing or malformed, log an error and deny everything. Test it with a missing file and a truncated one.
Solution
# policy_file.py
import json, logging
logging.basicConfig(format="%(levelname)s %(message)s")
def load_policy(path):
try:
with open(path) as f:
return {(r["user"], r["action"]) for r in json.load(f)}
except (OSError, ValueError, KeyError, TypeError) as exc:
logging.error("policy %s unusable (%r); denying everything", path, exc)
return set()
with open("bad.json", "w") as f:
f.write('[{"user": "alice", "action": "read"},') # truncated write
for path in ["missing.json", "bad.json"]:
policy = load_policy(path)
print(path, "-> alice read allowed:", ("alice", "read") in policy)
$ python policy_file.py
ERROR policy missing.json unusable (FileNotFoundError(2, 'No such file or directory')); denying everything
missing.json -> alice read allowed: False
ERROR policy bad.json unusable (JSONDecodeError('Expecting value: line 1 column 38 (char 37)')); denying everything
bad.json -> alice read allowed: False
An empty set is the fail-closed policy: nothing is granted. KeyError and TypeError are caught too,
because a file that’s valid JSON but has the wrong shape is just as unusable. JSONDecodeError is a
subclass of ValueError, so it’s covered.