Context
fourpointo is a Flask app I built and self-host that takes an uploaded assignment brief (PDF or DOCX) and uses an LLM to generate a structured task checklist from it. It's a real tool I use myself, and a portfolio piece.
I've been working through TryHackMe's Jr Pentester path, and one of the labs covered exploiting an unrestricted file upload: a server that trusted a file's extension instead of its actual content, allowing a .phtml webshell to be uploaded and executed.
After finishing that lab, I wanted to check whether fourpointo had the same class of weakness. Not by reading my own source code top to bottom, but by testing it the same way I'd test someone else's app: black-box, from the outside, the way an actual attacker or reviewer would.
Setup
To test this safely, I didn't want to throw unverified payloads at my live, internet-facing instance. So I cloned fourpointo onto a separate VM, set up a fresh Python virtual environment, installed dependencies, and got a local copy running independently of production. This also surfaced an unrelated environment quirk (a database schema that had drifted out of sync between my dev machine and production), which I fixed locally but is a separate issue from what's documented here.
With a working local copy, I had an isolated sandbox to actually break things in.
Discovery
fourpointo's upload field is restricted client-side with an accept attribute limiting the file picker to PDF and DOCX. That's a UI convenience, not a security control, so the first thing worth checking is whether the server enforces the same restriction, or only the browser does.
Test 1: Renamed file with a disallowed extension
I created a harmless test file and renamed it to .phtml, then tried uploading it both through the browser's file picker and by switching the picker to "All Files." The server rejected it. Good sign, extension-based filtering was at least present.
To rule out the picker itself doing the filtering rather than the server, I also took a real PDF, renamed it to test1.phtml, and uploaded that, real PDF content, disallowed extension. Still rejected. This confirmed the rejection was happening server-side, and that it was extension-based rather than content-based, at least in this direction.
Test 2: Valid extension, invalid content
The more interesting question was whether the check went any deeper than the filename. I created a file with a .pdf extension but garbage content (fake.pdf, containing the text "not a real pdf"). I uploaded it through the normal form.
It was accepted. The server didn't reject it. The user-facing response was a generic "Something went wrong. Please try again.", but the server's own logs showed the real cause: an unhandled exception.
File "app.py", line 67, in extract_text
doc = fitz.open(stream=file.read(), filetype="pdf")
pymupdf.mupdf.FzErrorFormat: code=7: no objects found
pymupdf.FileDataError: Failed to open stream
The relevant code, before the fix:
def extract_text(file):
filename = file.filename
if filename.endswith('.pdf'):
doc = fitz.open(stream=file.read(), filetype="pdf")
text = ""
for page in doc:
text += page.get_text()
return text
elif filename.endswith('.docx'):
import docx
doc = docx.Document(file)
text = ""
for para in doc.paragraphs:
text += para.text + "\n"
return text
else:
return None
The validation logic only checks whether the filename string ends in .pdf or .docx. Nothing inspects the actual bytes of the file before handing it to PyMuPDF to parse as a real PDF. The library itself caught the mismatch, but by then the request had no graceful way to fail, so it crashed instead of returning a clean error.
Separately, the upload route saved the file to disk before any of this validation ran, meaning even a rejected file would already be written to static/uploads/ first.
Impact
It's worth being precise about what this bug does and doesn't allow, rather than overstating it.
This is not remote code execution. Unlike the THM lab that inspired this test, fourpointo runs on Flask and Gunicorn, with no PHP interpreter in the stack, so a disguised file extension has no path to being executed as code on the server.
What it does demonstrate:
-
Insufficient input validation. The check trusts the filename over the actual content, the same root cause as the lab's
.phtmlbypass, just without that bypass's consequence here. - Missing error handling around a fallible operation. A malformed file causes an unhandled exception. The user only saw a generic "Something went wrong. Please try again." rather than the underlying error, but the full traceback, including internal file paths, was written to the server's own logs. A clean rejection should have been possible at the validation layer instead of relying on a catch-all handler further up the stack.
- Unvalidated files were written to disk before being checked. A rejected upload still left a file behind.
I tested whether this could escalate into a denial-of-service condition by checking if the crash brought down the whole server process or just the single request. After triggering the crash, the server continued responding normally to other requests immediately afterward. The impact is isolated to the failing request, not the application's overall availability.
Overall severity: low to medium. A real input validation gap with a reliability impact (an ugly failure mode for users), not a security compromise.
The Fix
Two changes, both in the file-handling logic.
Magic byte verification before parsing. Real PDF files begin with the bytes %PDF-. Real DOCX files (which are ZIP archives internally) begin with PK\x03\x04. Checking these few bytes before attempting to parse the file catches an obviously fake file immediately and cheaply:
def extract_text(file):
filename = file.filename
if filename.endswith('.pdf'):
header = file.read(5)
file.stream.seek(0)
if header != b'%PDF-':
return None
try:
doc = fitz.open(stream=file.read(), filetype="pdf")
text = ""
for page in doc:
text += page.get_text()
return text
except Exception:
return None
elif filename.endswith('.docx'):
header = file.read(4)
file.stream.seek(0)
if header != b'PK\x03\x04':
return None
try:
import docx
doc = docx.Document(file)
text = ""
for para in doc.paragraphs:
text += para.text + "\n"
return text
except Exception:
return None
else:
return None
Error handling around the actual parse. Even a file that passes the magic byte check could still be corrupted or malformed internally. Wrapping the parsing call in a try/except means that case fails the same clean way, returning None rather than propagating an unhandled exception.
One side effect worth noting: extract_text now returns None for two different reasons, an unsupported extension, or a supported extension with content that fails the magic byte check, and the route can't tell which happened. Both currently surface as "Unsupported file type," which is accurate for the first case but a little misleading for the second, since the file's extension was actually fine. A more precise message ("This file doesn't appear to be a valid PDF") would better reflect what actually failed, though it doesn't affect the security or reliability of the fix itself.
Validate before writing to disk. The upload route previously saved the file before checking if extract_text could read it. Reordering this means a rejected file never touches disk in the first place:
filename = f"{uuid.uuid4()}_{pdf.filename}"
text = extract_text(pdf)
if text is None:
return {"error": "Unsupported file type. Please upload a PDF or DOCX."}, 400
upload_folder = os.path.join('static', 'uploads')
os.makedirs(upload_folder, exist_ok=True)
filepath = os.path.join(upload_folder, filename)
pdf.stream.seek(0)
pdf.save(filepath)
After this fix, re-uploading fake.pdf returns a clean response instead of a crash:
Unsupported file type. Please upload a PDF or DOCX.
I also re-tested the original .phtml upload attempt to confirm the fix didn't change behavior there. It was still rejected, as before.
Takeaway
The THM lab's lesson, that a file's claimed type and its actual content are two different things, applies just as directly to my own code as it does to a deliberately vulnerable training box. The difference here wasn't the size of the consequence, it was that nobody had asked the question yet.
Testing your own projects the same way you'd test someone else's, without reading the source first to confirm your assumptions, is a genuinely useful habit. It catches the gap between what you assume your code does and what it actually does.










Top comments (0)