<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Zeyrian Faris</title>
    <description>The latest articles on DEV Community by Zeyrian Faris (@zeyrian_faris).</description>
    <link>https://dev.to/zeyrian_faris</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3950809%2F08e57afc-a6c6-4b72-bc60-0be9c27653db.jpg</url>
      <title>DEV Community: Zeyrian Faris</title>
      <link>https://dev.to/zeyrian_faris</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zeyrian_faris"/>
    <language>en</language>
    <item>
      <title>Testing fourpointo Against Malicious Uploads: Prompt Injection and Stored XSS</title>
      <dc:creator>Zeyrian Faris</dc:creator>
      <pubDate>Sun, 28 Jun 2026 12:01:29 +0000</pubDate>
      <link>https://dev.to/zeyrian_faris/testing-fourpointo-against-malicious-uploads-prompt-injection-and-stored-xss-23a9</link>
      <guid>https://dev.to/zeyrian_faris/testing-fourpointo-against-malicious-uploads-prompt-injection-and-stored-xss-23a9</guid>
      <description>&lt;p&gt;fourpointo is a self-hosted Flask app I built that generates AI-powered task checklists and rubric breakdowns from uploaded assignment PDFs. It uses Groq's LLaMA 3.3 70B for extraction, SQLite for storage and Gunicorn behind a Cloudflare Tunnel.&lt;/p&gt;

&lt;p&gt;After fixing a magic-byte validation bug found during normal use (a fake PDF was causing an unhandled crash in PyMuPDF), I wanted to go further and actually probe the upload pipeline the way an attacker might. This writeup documents that process: the setup, the test cases and the results.&lt;/p&gt;

&lt;p&gt;All testing was done locally in a Kali VM against a local copy of fourpointo. No production data or other users were involved at any point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;The first step was building a valid baseline PDF to work from. A blank or garbage file wouldn't tell me anything useful since fourpointo already rejects those.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa775mibgl16260if0xnn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa775mibgl16260if0xnn.png" alt=" " width="470" height="95"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This gave me &lt;code&gt;real.pdf&lt;/code&gt;, a small but genuinely valid PDF I could use as raw material for the rest of the testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 1: Input Validation (Magic Bytes and Content Gate)
&lt;/h2&gt;

&lt;p&gt;Before testing anything more advanced, I wanted to confirm the existing validation layers actually worked. fourpointo has two gates:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A magic-byte check that rejects files that aren't structurally PDFs&lt;/li&gt;
&lt;li&gt;A content gate (an LLM call) that rejects PDFs that don't read like an assignment specification&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To test the second gate, I truncated a valid PDF so the header would pass but the internal structure would be incomplete.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3s18dmktgnh3gn374xh8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3s18dmktgnh3gn374xh8.png" alt=" " width="352" height="102"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Uploading the truncated file produced a clean rejection rather than a crash or a silent failure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpecujcs1brwt9tsg9br3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpecujcs1brwt9tsg9br3.png" alt=" " width="462" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; Both gates behaved as intended. The truncated file still had a valid PDF header (since the magic bytes survive truncation) but lacked enough coherent content for the second gate to accept it, and it was rejected with a clear message instead of breaking anything downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a Realistic Specification PDF
&lt;/h2&gt;

&lt;p&gt;To test prompt injection and stored XSS properly, I needed a PDF that would actually pass the content gate, meaning it had to look like a real assignment specification with a Tasks section and a Grading Criteria section.&lt;/p&gt;

&lt;p&gt;I wrote a small Python script using fpdf2 to generate this on demand, which also made it easy to produce variants later by editing one line at a time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc254u28a6uso08a4vlbs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc254u28a6uso08a4vlbs.png" alt=" " width="800" height="253"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiu6t3ijgyfh6g78g987b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiu6t3ijgyfh6g78g987b.png" alt=" " width="187" height="43"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxde1ygamr9koqifg8toe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxde1ygamr9koqifg8toe.png" alt=" " width="406" height="224"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fesrzvt21ls3nryov2oy5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fesrzvt21ls3nryov2oy5.png" alt=" " width="799" height="357"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once the script ran cleanly, I uploaded the resulting &lt;code&gt;spec.pdf&lt;/code&gt; as a baseline with no malicious content. It passed the content gate and generated a normal checklist.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff0a2yg86trwstki2ih6o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff0a2yg86trwstki2ih6o.png" alt=" " width="800" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This confirmed the test harness worked end to end before introducing anything adversarial.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 2: Prompt Injection
&lt;/h2&gt;

&lt;p&gt;fourpointo pipes extracted PDF text directly into an LLM call to generate tasks and rubric criteria. This is exactly the kind of pipeline that's vulnerable to prompt injection if the document content isn't treated as untrusted input.&lt;/p&gt;

&lt;p&gt;Rather than using an obvious phrase like "ignore all previous instructions," I used a more disguised pattern that mimics how a real injection attempt would try to impersonate a system-level instruction embedded inside user-supplied content.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcfvkovroiw5f11dk8tmw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcfvkovroiw5f11dk8tmw.png" alt=" " width="800" height="35"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I built this into a new PDF and confirmed it was generated correctly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdcpqlr87kozlbkt6zs75.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdcpqlr87kozlbkt6zs75.png" alt=" " width="486" height="175"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After uploading it and generating the checklist, I checked the raw JSON output for the rubric extraction.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe8s6nu16yo2glrk6ymij.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe8s6nu16yo2glrk6ymij.png" alt=" " width="800" height="506"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The injected text never appears anywhere in the output. The model extracted the real tasks and the real grading criteria and nothing else.&lt;/p&gt;

&lt;p&gt;I also checked the rendered project page in the browser to confirm the same thing held true for the task list, not just the rubric.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjwm9b5drv61w6kdyf1cb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjwm9b5drv61w6kdyf1cb.png" alt=" " width="800" height="365"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; The injection attempt failed. fourpointo's extraction stayed accurate even when an instruction-like phrase was embedded directly inside the task text. It's worth noting that fourpointo's AI layer only reads and displays content. It doesn't grade anything or make decisions that affect a real outcome, so even a successful injection here would have limited impact beyond corrupting the displayed checklist. Still, this is a meaningful result because it shows the extraction step doesn't blindly follow instructions found inside untrusted document content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 3: Stored XSS via PDF Content
&lt;/h2&gt;

&lt;p&gt;Next, I wanted to know whether a script tag embedded inside a PDF's task text could survive the full pipeline (extraction, storage and rendering) and execute in the browser. I disguised the payload as part of a legitimate-looking task rather than dropping it in isolation, since that's closer to how a real attacker would try to slip it past a casual read.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbf0w9gqd2irt1kgyv8o3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbf0w9gqd2irt1kgyv8o3.png" alt=" " width="798" height="86"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I uploaded the resulting PDF and generated the checklist, then checked every task in the rendered UI.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F14e17lx68l699s6jsgao.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F14e17lx68l699s6jsgao.png" alt=" " width="800" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No alert fired, and none of the displayed tasks contained the script tag at all. Looking at the task labels (&lt;code&gt;Analyze Dataset&lt;/code&gt;, &lt;code&gt;Write Report&lt;/code&gt;, &lt;code&gt;Check Formatting&lt;/code&gt;, &lt;code&gt;Create Charts&lt;/code&gt;, &lt;code&gt;Submit Report&lt;/code&gt;), it's clear the AI isn't echoing the raw text back. It's paraphrasing each task into a short label before it ever reaches the frontend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; No XSS was observed, but the reason matters. This wasn't a deliberate sanitization control catching the payload. It was the summarization step rewriting the content before the payload could survive long enough to be rendered. If fourpointo's prompt ever changes to extract tasks verbatim instead of summarizing them, this protection could disappear without anyone noticing. It's a real mitigation today, but not one to rely on long term.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 4: Stored XSS via Direct Form Input
&lt;/h2&gt;

&lt;p&gt;Since the PDF-based test was inconclusive about actual output escaping (the payload never survived to be rendered), I tested the rendering layer directly by typing a script tag straight into the project name field. This field gets stored and displayed with no LLM step in between, which isolates the question of whether the frontend escapes HTML on output.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F02buoir413oyllf3jq5p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F02buoir413oyllf3jq5p.png" alt=" " width="473" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After creating the project, I checked how the name rendered on the project page and in the sidebar.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7bfj8nylhgy5pqxz9p69.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7bfj8nylhgy5pqxz9p69.png" alt=" " width="799" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The page title and sidebar entry both showed the literal text &lt;code&gt;&amp;lt;script&amp;gt;alert('XSS via project name')&amp;lt;/script&amp;gt;&lt;/code&gt; instead of executing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; Confirmed. fourpointo properly escapes this field on render. The script tag is treated as plain text rather than executable HTML, almost certainly because the frontend rendering layer (whether that's React's default text handling or template auto-escaping) doesn't interpret stored strings as raw HTML.&lt;/p&gt;

&lt;p&gt;One caveat worth being precise about here: since fourpointo currently has no sharing, admin view, or export feature, this would technically be self-XSS rather than exploitable stored XSS, because there's no path for the payload to reach another user's session. The mechanism is confirmed either way, and the escaping itself is a real control that would matter immediately if any multi-user-facing feature (sharing, an admin dashboard or exported reports) were added later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary of Findings
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Magic-byte validation&lt;/td&gt;
&lt;td&gt;Pass&lt;/td&gt;
&lt;td&gt;Rejects non-PDF files correctly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content gate validation&lt;/td&gt;
&lt;td&gt;Pass&lt;/td&gt;
&lt;td&gt;Rejects structurally invalid or unconvincing specs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt injection (disguised)&lt;/td&gt;
&lt;td&gt;Pass&lt;/td&gt;
&lt;td&gt;Injected instruction was not followed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stored XSS via PDF content&lt;/td&gt;
&lt;td&gt;Pass (incidental)&lt;/td&gt;
&lt;td&gt;Payload never survived extraction due to paraphrasing, not deliberate sanitization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stored XSS via form input&lt;/td&gt;
&lt;td&gt;Pass (deliberate)&lt;/td&gt;
&lt;td&gt;Output is properly HTML-escaped on render&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Recommendations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Continue treating all extracted document text as untrusted input. The current prompt injection resistance held up in this round of testing, but it should be re-verified any time the extraction prompt or model changes.&lt;/li&gt;
&lt;li&gt;Don't rely on the LLM's paraphrasing behavior as a security control against XSS. If task extraction is ever changed to preserve verbatim text, explicit output encoding should be added at that point rather than assumed.&lt;/li&gt;
&lt;li&gt;The form-field escaping currently in place is a genuine and correct control. If sharing, an admin view or export features are added in the future, this is the exact protection that would prevent a self-XSS finding from becoming a real stored XSS affecting other users.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Across four separate test categories, fourpointo held up better than expected for a personal project with a small user base. Input validation correctly filters malformed and irrelevant uploads, the AI extraction layer resisted a disguised prompt injection attempt and the frontend properly escapes user-controlled output. The one point worth continued attention is that the XSS resistance in the document pipeline is currently a side effect of summarization rather than a deliberate control, which is the kind of gap that's easy to lose track of as the app evolves.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>llm</category>
      <category>python</category>
    </item>
    <item>
      <title>Finding a File Upload Validation Bug in My Own App</title>
      <dc:creator>Zeyrian Faris</dc:creator>
      <pubDate>Wed, 24 Jun 2026 06:49:33 +0000</pubDate>
      <link>https://dev.to/zeyrian_faris/finding-a-file-upload-validation-bug-in-my-own-app-pgi</link>
      <guid>https://dev.to/zeyrian_faris/finding-a-file-upload-validation-bug-in-my-own-app-pgi</guid>
      <description>&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;.phtml&lt;/code&gt; webshell to be uploaded and executed.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;With a working local copy, I had an isolated sandbox to actually break things in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discovery
&lt;/h2&gt;

&lt;p&gt;fourpointo's upload field is restricted client-side with an &lt;code&gt;accept&lt;/code&gt; 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 &lt;strong&gt;server&lt;/strong&gt; enforces the same restriction, or only the browser does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test 1: Renamed file with a disallowed extension&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I created a harmless test file and renamed it to &lt;code&gt;.phtml&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6wdv2deypybw97y7bn65.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6wdv2deypybw97y7bn65.png" alt=" " width="499" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9sozbs03uuxk8ok6hnoh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9sozbs03uuxk8ok6hnoh.png" alt=" " width="488" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To rule out the picker itself doing the filtering rather than the server, I also took a real PDF, renamed it to &lt;code&gt;test1.phtml&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn564yued3cv2c6hbjpb1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn564yued3cv2c6hbjpb1.png" alt=" " width="233" height="98"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flqjeqsroxgooetcribkm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flqjeqsroxgooetcribkm.png" alt=" " width="484" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test 2: Valid extension, invalid content&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The more interesting question was whether the check went any deeper than the filename. I created a file with a &lt;code&gt;.pdf&lt;/code&gt; extension but garbage content (&lt;code&gt;fake.pdf&lt;/code&gt;, containing the text "not a real pdf"). I uploaded it through the normal form.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmnrj97mehg4atd31zyyh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmnrj97mehg4atd31zyyh.png" alt=" " width="304" height="45"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fthsdmd7kh6ufdsucvuaz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fthsdmd7kh6ufdsucvuaz.png" alt=" " width="486" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2yk0unya2k7bysk9xeqk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2yk0unya2k7bysk9xeqk.png" alt=" " width="800" height="637"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relevant code, before the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.pdf&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;filetype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.docx&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;docx&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;docx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;para&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paragraphs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;para&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The validation logic only checks whether the filename string ends in &lt;code&gt;.pdf&lt;/code&gt; or &lt;code&gt;.docx&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;static/uploads/&lt;/code&gt; first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Impact
&lt;/h2&gt;

&lt;p&gt;It's worth being precise about what this bug does and doesn't allow, rather than overstating it.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;What it does demonstrate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Insufficient input validation.&lt;/strong&gt; The check trusts the filename over the actual content, the same root cause as the lab's &lt;code&gt;.phtml&lt;/code&gt; bypass, just without that bypass's consequence here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing error handling around a fallible operation.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unvalidated files were written to disk before being checked.&lt;/strong&gt; A rejected upload still left a file behind.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Farmbnthl0dcoepsg35ps.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Farmbnthl0dcoepsg35ps.png" alt=" " width="634" height="113"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Overall severity: low to medium. A real input validation gap with a reliability impact (an ugly failure mode for users), not a security compromise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Two changes, both in the file-handling logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Magic byte verification before parsing.&lt;/strong&gt; Real PDF files begin with the bytes &lt;code&gt;%PDF-&lt;/code&gt;. Real DOCX files (which are ZIP archives internally) begin with &lt;code&gt;PK\x03\x04&lt;/code&gt;. Checking these few bytes before attempting to parse the file catches an obviously fake file immediately and cheaply:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fej4hl0dw2u097u8uo45b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fej4hl0dw2u097u8uo45b.png" alt=" " width="576" height="586"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.pdf&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seek&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%PDF-&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;filetype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.docx&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seek&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PK&lt;/span&gt;&lt;span class="se"&gt;\x03\x04&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;docx&lt;/span&gt;
            &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;docx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;para&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paragraphs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;para&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Error handling around the actual parse.&lt;/strong&gt; 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 &lt;code&gt;None&lt;/code&gt; rather than propagating an unhandled exception.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate before writing to disk.&lt;/strong&gt; The upload route previously saved the file before checking if &lt;code&gt;extract_text&lt;/code&gt; could read it. Reordering this means a rejected file never touches disk in the first place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unsupported file type. Please upload a PDF or DOCX.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;

&lt;span class="n"&gt;upload_folder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;static&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;uploads&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makedirs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upload_folder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;filepath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upload_folder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seek&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After this fix, re-uploading &lt;code&gt;fake.pdf&lt;/code&gt; returns a clean response instead of a crash:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd4vsdeu3yzktw62woxvx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd4vsdeu3yzktw62woxvx.png" alt=" " width="481" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Unsupported file type. Please upload a PDF or DOCX.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I also re-tested the original &lt;code&gt;.phtml&lt;/code&gt; upload attempt to confirm the fix didn't change behavior there. It was still rejected, as before.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff2hk6u2wrinoo83ufmvy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff2hk6u2wrinoo83ufmvy.png" alt=" " width="475" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Found a Source Code Disclosure Bug in My Own Flask App With Gobuster</title>
      <dc:creator>Zeyrian Faris</dc:creator>
      <pubDate>Sat, 20 Jun 2026 03:03:44 +0000</pubDate>
      <link>https://dev.to/zeyrian_faris/i-found-a-source-code-disclosure-bug-in-my-own-flask-app-with-gobuster-42n8</link>
      <guid>https://dev.to/zeyrian_faris/i-found-a-source-code-disclosure-bug-in-my-own-flask-app-with-gobuster-42n8</guid>
      <description>&lt;p&gt;&lt;strong&gt;Context&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'm a first-year Cybersecurity and Digital Forensics student, currently working through TryHackMe's Jr Pentester path. While practicing directory enumeration with gobuster, I had an idea: rather than only running it against lab targets, why not point it at something I actually built and care about?&lt;/p&gt;

&lt;p&gt;That something is fourpointo. It Flask app I'm developing that generates AI task lists and rubric breakdowns from uploaded assignment PDFs, self-hosted on a Dell server and deployed via Cloudflare Tunnel.&lt;/p&gt;

&lt;p&gt;I wasn't expecting to find anything. I was mostly testing whether I understood the tool. I ended up finding a real, textbook misconfiguration  and figured it was worth documenting properly, both as a learning exercise and as a small case study in why this category of bug is so common.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Setup&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To do this safely, I didn't touch the live production app (fourpointo has real registered users and testing against it directly wasn't worth the risk). Instead, I cloned the public GitHub repo and ran a local dev copy on an isolated Kali VM, hitting localhost only.&lt;/p&gt;

&lt;p&gt;I then deliberately introduced a misconfiguration I wanted to study: I copied database.py, one of the app's core server-side source files, into the static/ directory.&lt;/p&gt;

&lt;p&gt;Why this matters: in Flask, static/ is auto-served by the framework with no route required. Anything dropped in there, intentionally or by accident, is publicly reachable. It's meant to hold CSS, JS, images: client-facing assets. Files in templates/, by contrast, are not directly reachable; they only render through actual app routes. Mixing a server-side source file into static/ blurs a trust boundary that should be kept firm.&lt;/p&gt;

&lt;p&gt;This is a realistic mistake. It's easy to imagine a developer copying a file for a quick test, or a build script misplacing something, and forgetting to remove it before deploying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discovery&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With the dev server running locally, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gobuster dir -u http://localhost:5000/static -w /usr/share/wordlists/dirb/common.txt -x html,htm,php,py,js,json,txt,xml,bak,old,zip,sql,env,config,yml -t 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;database.py     (Status: 200) [Size: 7739]
script.js        (Status: 200) [Size: 8746]
Progress: 73808 / 73808 (100.00%)
Finished
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuogrlkqv7u3z0bu7smtb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuogrlkqv7u3z0bu7smtb.png" alt=" " width="765" height="396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;script.js showing up is expected as it is a legitimate static asset. database.py showing up alongside it is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confirming the Finding&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A 200 status from gobuster tells you a path exists and returned content but it doesn't tell you what that content is. To confirm actual impact, I pulled the file directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl http://localhost:5000/static/database.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This returned the full, raw source of database.py. No authentication, no warning, just plain text served like any other static asset.&lt;/p&gt;

&lt;p&gt;The file contained:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full database schema (table definitions for projects, tasks, and others, including column names and relationships)&lt;/li&gt;
&lt;li&gt;Application logic for handling user data, including how passwords are hashed before being written to the database&lt;/li&gt;
&lt;li&gt;Enough structural detail to understand exactly how the app's data model and core functions work, without needing to guess&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To be clear: no real user data, hardcoded secrets, or API keys were exposed in this case. Fourpointo correctly keeps those in environment variables, not in source. But that's somewhat beside the point. The schema and logic disclosure alone is a meaningful leak on its own, and in a less careful codebase, this exact misconfiguration could just as easily have exposed live credentials.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F72akl4qqd6jjtmcqh10z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F72akl4qqd6jjtmcqh10z.png" alt=" " width="787" height="623"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why This Matters&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Source code disclosure is generally underrated as a finding because it doesn't look as dramatic as, say, SQL injection or an exposed admin panel. But it directly enables both:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Targeted attacks: knowing exact table names, column names, and queries removes the guesswork an attacker would otherwise need for SQL injection, IDOR, or logic-abuse attempts.&lt;/li&gt;
&lt;li&gt;Reconnaissance for the next bug: reading real application logic often reveals other weaknesses (missing validation, unsafe assumptions, auth logic quirks) that would be far harder to find through black-box testing alone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It's also a good demonstration of why directory brute-forcing matters in a real pentest. In practice you rarely know a target's folder structure in advance. You fingerprint the stack (Flask, in this case, identifiable through default error pages, cookie naming, etc.), pick a wordlist suited to that stack, and brute-force from there. Knowing Flask serves static/ by default is exactly the kind of framework-specific knowledge that turns a generic scan into a targeted one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remediation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix is straightforward: keep server-side source code out of any directory the web server treats as public:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never place .py (or other server-side source) files inside static/&lt;/li&gt;
&lt;li&gt;At the web server/proxy level, explicitly deny serving source file extensions as a defense-in-depth measure, e.g. for Nginx:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;location ~* \.py$ {
      deny all;
  }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Periodically audit what's actually inside static/ as it's easy for stray files to accumulate over time without anyone noticing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Takeaway&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The lesson that stuck with me most: a directory's purpose and a directory's actual contents can quietly drift apart, and nothing forces you to notice until someone runs a scan. Treating static/ (or any auto-served directory) as inherently safe just because of what it's meant to hold, rather than auditing what's actually in there, is exactly the kind of assumption that creates this category of bug.&lt;/p&gt;

&lt;p&gt;Testing this against my own app, rather than only against lab targets, was a useful exercise precisely because the stakes felt real even at a small scale: this is a live app with actual users, and the same gap could plausibly have existed in production without me thinking to check.&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>python</category>
      <category>beginners</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I self-hosted my Flask app on an old laptop for almost free</title>
      <dc:creator>Zeyrian Faris</dc:creator>
      <pubDate>Mon, 25 May 2026 14:37:30 +0000</pubDate>
      <link>https://dev.to/zeyrian_faris/how-i-self-hosted-my-flask-app-on-an-old-laptop-for-almost-free-52h9</link>
      <guid>https://dev.to/zeyrian_faris/how-i-self-hosted-my-flask-app-on-an-old-laptop-for-almost-free-52h9</guid>
      <description>&lt;p&gt;I recently built fourpointo, a Flask web app for polytechnic students to manage assignments. Once it was working locally, I needed to get it live without paying for cloud hosting every month.&lt;/p&gt;

&lt;p&gt;Then I realised I had an old, unused laptop which I was trying to find a purpose for. I figured it would be great to use it as a home server to host my website and it saves costs.&lt;/p&gt;

&lt;p&gt;I wiped the laptop and went with Ubuntu Server since it's lightweight and widely used. There's plenty of documentation if I got stuck.&lt;/p&gt;

&lt;p&gt;I then cloned fourpointo from my new laptop onto my server.&lt;/p&gt;

&lt;p&gt;When developing fourpointo, I used Python's Flask library. However, Flask's built-in server is single-threaded and not designed to handle multiple requests at once. Gunicorn is a production-grade server that runs multiple workers so it can handle real traffic.&lt;/p&gt;

&lt;p&gt;After installing Gunicorn, I got the domain fourpointo.app from Namecheap which serves as the URL for my app.&lt;/p&gt;

&lt;p&gt;I then used Cloudflare Tunnel because it routes traffic through Cloudflare's servers, so my home IP stays hidden rather than being exposed publicly.&lt;/p&gt;

&lt;p&gt;One issue I ran into was fourpointo going down every time I closed the lid of my server. Then I realised the server cannot go to sleep, even if the lid is closed. So I edited a systemd script using the Command Line Interface to set it so that it doesn't go to sleep when the lid is closed.&lt;/p&gt;

&lt;p&gt;Another issue I faced was fixing bugs and pushing updates remotely. In order to update fourpointo, I needed to SSH into the server. However, that only worked if I was on the same wifi as my server. So remote fixing was impossible. To solve this issue, I used Tailscale which acts as a bridge between my server and my laptop. Tailscale generated an IP for me to use when I want to SSH into the server remotely.&lt;/p&gt;

&lt;p&gt;Through this experience, I learnt the basics of server and network administration using Linux. I will continue to push updates to fourpointo to enhance users' experiences and hopefully in the future, I will be able to scale the app up.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>linux</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
