skip to content

Why must traceback.format_exc() output never be sent in an HTTP response body?

level: juniorimportance: must knowfreq 60%

answer

  1. Error text is an information channel
  2. What the frames disclose about the host
  3. Paths, source lines, chained causes, reprs
  4. One-way: log it, return an id
  5. uuid reference joins the two

basics

~20 s

A formatted traceback exposes absolute file paths, the module layout, the failing source lines and sometimes the repr of local values such as connection strings. Log it server side under an opaque reference id and return only that id with a generic message.

solid answer

~40 s

`traceback.format_exc()` renders the whole current exception chain as text: every frame's absolute file path, line number, function name and source line, plus any exception chained through `__cause__` or `__context__`, and since 3.11 the caret markers pointing at the exact failing sub-expression. `traceback.TracebackException` can even be built with local-variable capture, which puts every frame's locals into the output. Sent to a caller, that hands over your deployment layout, your dependency paths and versions, your internal call graph, and occasionally a credential straight out of a `repr`. The flow has to be one-way: format the traceback into your log sink, mint a short random reference with `uuid.uuid4()`, and return a 500 carrying only a generic message and that reference. Support can join the reference back to the log line; the caller learns nothing about the process.

code

python · 14 lines
python
import traceback

def load(path):
    with open(path) as handle:
        return handle.read()

try:
    load("/srv/catalogue/import.cfg")
except OSError:
    detail = traceback.format_exc()

print(detail)
print("leaks the path:", "/srv/catalogue/import.cfg" in detail)
print("leaks the source line:", "with open(path) as handle" in detail)

go deeper

for a junior

Be ready to say what traceback.format_exc() puts in the string — file paths, line numbers and source lines — and to state the rule: log it on the server, return a generic message plus a reference id.

for a middle

Explain the mechanics: the frame walk, the cause/context chain, and why the exception message alone can leak as much as the frames. Show the boundary code that mints an id before logging.

for a senior

Demonstrate that you own the error boundary for a whole service: one handler, no per-request debug switch, correlation ids threaded into every log record, and a policy for what an error response is allowed to contain.

for a principal

Own the tradeoff between debuggability and disclosure across teams: where the traceback is retained, who can read that sink, how long, and how you keep an incident-time convenience flag from becoming a permanent exposure.

## What a traceback actually contains `traceback.format_exc()` returns a string built from the exception currently being handled — it is the same text the interpreter would have printed had the exception gone unhandled. For each frame in the traceback chain it emits the absolute path of the source file, the line number, the enclosing function name, and the source line itself read back off disk. Since 3.11 (PEP 657) it additionally underlines the exact sub-expression that failed with caret markers, which makes the leaked source even more precise. If the exception was raised while handling another one, the whole chain is rendered, joined by "The above exception was the direct cause of..." (explicit `raise ... from`, i.e. `__cause__`) or "During handling of the above exception..." (implicit `__context__`). And `traceback.TracebackException`, constructed with local capture, will render each frame's local variables too. Every one of those is information about your system that a caller has no business having. * **Paths** disclose the deployment layout, the operating-system user, the install prefix, and the exact versions of third-party libraries because version numbers routinely appear in site-packages directory names. * **Source lines** disclose logic: which checks run in which order, what the validation actually tests, where the shortcuts are. * **The chain** discloses internal services: a driver exception wrapped in an application exception names the internal host that could not be reached. * **Reprs** disclose data. An exception message is often built by interpolating the offending value; a database driver's error frequently embeds the connection string; a signing helper's error can embed the key material it was handed. Even the exception *type* leaks: a `KeyError` where a `PermissionError` was expected tells an attacker whether the record they guessed at exists. ## The correct shape of an error boundary There is exactly one place a traceback belongs — the server-side log — and exactly one thing that crosses back to the caller: an identifier. ```python import logging, traceback, uuid log = logging.getLogger(__name__) def handle(request): try: return do_work(request) except Exception: ref = uuid.uuid4().hex[:12] log.error("request failed ref=%s\n%s", ref, traceback.format_exc()) return 500, {"error": "internal error", "reference": ref} ``` The caller gets a stable, opaque token they can quote to support; support greps the log for it and sees the full traceback. Note that the reference is generated *before* the log write, so the two can never disagree, and that the traceback is formatted once, at the boundary, rather than being carried around as a string. ## The failure modes to name in an interview **"I only return `str(exc)`, not the traceback."** The message is frequently the leakiest part. `FileNotFoundError` stringifies to the path it tried. A driver error stringifies to the DSN. A parse error stringifies to the input, which may be someone else's data. **"It is behind a debug flag."** Any flag that can be turned on in production will be turned on in production, usually at 3 a.m. by the person debugging the outage, and then left on. A framework's built-in debug page is the same hazard: it renders frames, locals and the environment, and it is a configuration mistake away from being public. **"The status code is 500, nobody reads the body."** Automated scanners read every body, and so does the browser console, the proxy log and the client-side error reporter that ships response bodies to a third party. **"It is an internal service."** Internal callers are exactly who a lateral-movement attacker is impersonating, and traceback text tends to be copied verbatim into tickets, chat channels and public issue trackers. ## Getting the same debugging value safely Nothing about this rule costs you observability. The full traceback still exists — with more context than the caller would ever have seen — in a place that has access control. Attach the correlation id to the log record so a request can be reconstructed end to end. If you want structure rather than a blob, `traceback.TracebackException` gives you the frames as objects you can serialize field by field, dropping paths to basenames and omitting locals entirely. What you must never do is make the decision at the wrong end of the wire: the process, not the caller, decides what the caller sees.

  • Beyond file paths, what else in a traceback is dangerous?
    The source lines expose your validation logic; the chained cause names internal hosts and drivers; the exception message often interpolates the offending value, which can be a connection string, a token or another user's data. Even the exception type is a signal: a KeyError where an authorization error was expected tells the caller the record exists.
  • How do you keep the full traceback for debugging without returning it?
    Format it once at the error boundary and write it to the log sink together with a short random reference id, then return only that reference and a generic message. Support looks the id up; the caller cannot derive anything from it. Attach the same id to the request's other log records so the whole request can be reconstructed.
  • Why is putting the traceback behind a debug flag not good enough?
    A flag that can be enabled in production eventually is, usually during an incident, and it is rarely turned back off. Worse, a per-request switch driven by a header or query parameter is attacker-controlled by construction. Make the safe path the only path so there is no configuration in which the traceback escapes.

A traceback is the incident report, not the press release: it names the room, the shelf and who was holding the key, so it goes in the file and the public gets a case number.

saying these in an interview costs you the question

  • Claims a traceback is safe because it holds no passwords
  • Returns str(exc) instead, assuming the message is harmless
  • Gates the traceback on a debug flag or a request header
  • Thinks a 500 status means nobody reads the body
  • Assumes internal-only callers make disclosure acceptable
  • Never logs the traceback anywhere after suppressing it

context