<?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: Dmitry Petrakov</title>
    <description>The latest articles on DEV Community by Dmitry Petrakov (@dimlight).</description>
    <link>https://dev.to/dimlight</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%2F4007809%2Fbb725926-329b-4cb6-b72c-be8b6b61b2a3.png</url>
      <title>DEV Community: Dmitry Petrakov</title>
      <link>https://dev.to/dimlight</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dimlight"/>
    <language>en</language>
    <item>
      <title>Secure File Upload in Go: 7 Attacks and How We Mitigated Them</title>
      <dc:creator>Dmitry Petrakov</dc:creator>
      <pubDate>Tue, 30 Jun 2026 08:15:18 +0000</pubDate>
      <link>https://dev.to/dimlight/secure-file-upload-in-go-7-attacks-and-how-we-mitigated-them-1j24</link>
      <guid>https://dev.to/dimlight/secure-file-upload-in-go-7-attacks-and-how-we-mitigated-them-1j24</guid>
      <description>&lt;p&gt;"Build a PDF upload form" – sounds like a 30-minute task. Claude/GPT will write the handler, we'll add &lt;code&gt;accept=".pdf"&lt;/code&gt; on the frontend, &lt;code&gt;multer&lt;/code&gt; on the backend – and we've got a working upload. Ship it.&lt;/p&gt;

&lt;p&gt;The problem is that a working upload and a secure upload are two very different things. The gap between them is a handful of vulnerabilities, each of which can turn your server into an entry point for an attacker.&lt;/p&gt;

&lt;p&gt;With the proliferation of LLM tools, the barrier to entry in software development has dropped dramatically. That's great – more people can build products. But along with the barrier to entry for development, the barrier to entry for vulnerabilities has dropped too. When an LLM generates file upload code, it solves the functional problem: accept a file, save it, process it. Security? "I'll add that later." And "later" usually comes after an incident.&lt;/p&gt;

&lt;p&gt;I decided not to wait for an incident and figured it out upfront: what attacks exist around file uploads, what happens when you forget about them, and how we defended against them in a real project.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclaimer.&lt;/strong&gt; This is not a universal security guide, nor a claim that "I know the right way." It's an engineering case study: how I designed upload protection for a specific service and what trade-offs I made. The example is a browser extension for converting &lt;a href="https://pdf2md.dev/" rel="noopener noreferrer"&gt;PDF to Markdown&lt;/a&gt;. A PDF converter by itself might not warrant this level of protection. But the approaches are universal and applicable to systems where the stakes are higher: medical documents, financial reports, legal scans, UGC platforms. I'm demonstrating the principles with real code – and you decide which ones apply to your case.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; – in this article, I break down 7 attacks on file upload and how I mitigated them in a Go backend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;File type spoofing&lt;/strong&gt; → magic bytes &lt;code&gt;%PDF&lt;/code&gt;, never trust the extension&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk exhaustion&lt;/strong&gt; → &lt;code&gt;MaxBytesReader&lt;/code&gt; + per-device slot limits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Path Traversal&lt;/strong&gt; → fixed filename &lt;code&gt;{UUID}/input.pdf&lt;/code&gt;, no user input in paths&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSRF&lt;/strong&gt; → DNS resolution before the request, redirect blocking, private IP denylist&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay attack&lt;/strong&gt; → nonce + timestamp + ECDSA signature on every request&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Device spoofing&lt;/strong&gt; → cryptographic identity via WebCrypto (ECDSA P-256)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application-level abuse&lt;/strong&gt; → rate limit + signature + slots&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Summary table with statuses – at the end of the article.&lt;/p&gt;




&lt;h2&gt;
  
  
  Upload Architecture: What Happens When You Send a File
&lt;/h2&gt;

&lt;p&gt;Before talking about attacks, let's look at the file's journey through the system:&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%2Fm5lt0nyc5zfg2ju09kuu.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%2Fm5lt0nyc5zfg2ju09kuu.png" alt="File's journey through the system" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two upload channels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Direct upload&lt;/strong&gt; – the user selects a file or drags and drops it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL upload&lt;/strong&gt; – the user provides a link to a PDF&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each channel carries its own set of threats, and each requires its own defenses.&lt;/p&gt;

&lt;p&gt;But before we dig into specific attacks, we need to understand the foundation that our entire security model rests on.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;All code snippets below are simplified excerpts from the real project, highlighting the key checks. The actual code may differ in error handling and additional edge cases.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Foundation: Anonymous Device Identity
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why so complex?&lt;/strong&gt; If your backend uses conventional authentication (JWT, sessions, OAuth) – you can skip this section; your auth layer already solves the identity problem. We describe this approach for a specific case: a product with no logins or accounts, where you still need to control load per-device. If your project includes authentication – use it, it's simpler and more reliable.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In most applications, file upload is protected by login – you have an account, the server knows who you are. Our browser extension has &lt;strong&gt;no registration and no accounts&lt;/strong&gt;. So how do we distinguish a legitimate user from an attacker? How do we rate-limit requests when there's no login?&lt;/p&gt;

&lt;p&gt;We solved this through &lt;strong&gt;cryptographic device identity&lt;/strong&gt; – essentially, each browser profile becomes an anonymous yet verifiable "account."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; this is not user tracking or covert identity fingerprinting. The &lt;code&gt;device_id&lt;/code&gt; is tied to a key pair in the IndexedDB of a specific browser profile. We don't know who the person is – we only know that &lt;em&gt;this same browser profile&lt;/em&gt; has made requests before. Incognito window = new device. Extension uninstalled = keys lost forever. At the auth-model level, &lt;code&gt;device_id&lt;/code&gt; provides no cryptographic linkage between devices. That said, the server operator still has indirect signals (IP, ASN) that could theoretically be used to hypothesize about device correlation – but that is not part of the auth protocol and is not used for identification.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  How It Works
&lt;/h3&gt;

&lt;p&gt;On first launch, the extension generates an ECDSA P-256 key pair via the WebCrypto API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keyPair&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ECDSA&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;namedCurve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;P-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// extractable = false – the key cannot be exported!&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sign&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;verify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical detail: &lt;code&gt;extractable: false&lt;/code&gt;. The private key is stored in the browser's IndexedDB, but it &lt;strong&gt;cannot be extracted&lt;/strong&gt; – not via JavaScript, not via DevTools, not via extensions. You can only ask the browser to sign data with this key.&lt;/p&gt;

&lt;p&gt;The extension then sends the public key to the server (&lt;code&gt;POST /register&lt;/code&gt;) and receives a &lt;code&gt;device_id&lt;/code&gt; and &lt;code&gt;device_token&lt;/code&gt; in response. From this point on, &lt;strong&gt;every API request&lt;/strong&gt; is signed with the private key:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Device identification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;X-Timestamp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Request time (±5 min window)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;X-Nonce&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unique request ID (anti-replay)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;X-Body-SHA256&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Body hash – data integrity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;X-Signature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ECDSA signature of all the above&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The signature covers: &lt;strong&gt;METHOD + PATH + TIMESTAMP + NONCE + BODY_HASH&lt;/strong&gt;. Forging it without the private key is impossible.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Signature ≠ encryption.&lt;/strong&gt; An ECDSA signature ensures the integrity and authenticity of a request, but not confidentiality. The file contents, token, and metadata are transmitted in plaintext without TLS. Request signing is a complement to HTTPS, not a replacement.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The server verifies the signature using the public key bound to the &lt;code&gt;device_id&lt;/code&gt;. If the signature doesn't match – the request is rejected, regardless of whether the token is valid.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Matters for File Upload
&lt;/h3&gt;

&lt;p&gt;This model gives us something you normally don't get without accounts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-device control&lt;/strong&gt; – we can limit the number of concurrent tasks, files per day, and requests per minute for each device (i.e., browser profile)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost of creating a "new account"&lt;/strong&gt; – an attacker can't just swap cookies or tokens. They need to generate a new key pair and go through registration, which is limited to &lt;strong&gt;5 attempts per IP per hour&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protection against token theft&lt;/strong&gt; – even if the &lt;code&gt;device_token&lt;/code&gt; leaks, it's useless without the private key (which can't be exported)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request integrity&lt;/strong&gt; – the request body (including the file) is covered by the signature via a SHA256 hash. Tampering with the file in transit is impossible&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Essentially, each browser profile gets its own unforgeable "passport." And all the limits we'll discuss next – slots, rate limits, size restrictions – work precisely because we can reliably identify the device.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Is this protection absolute?&lt;/strong&gt; No. A technically motivated attacker could write an emulator that reproduces the entire chain: key generation, registration, request signing – without a browser at all. But that's the whole point of the approach: we &lt;strong&gt;significantly raise the barrier to entry&lt;/strong&gt;. Without cryptographic identity, attacking the API takes a single curl command or a couple of clicks in Postman – fire off request after request, swapping headers. With our model, the attacker needs to implement ECDSA P-256, correctly form the canonical string, sign every request, manage nonces and timestamps – and all of this for a limit of 3 active slots and 5 registrations per hour per IP. The cost of the attack grows by orders of magnitude, while the payoff stays the same.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Attack 1: File Type Spoofing (Malicious File Upload)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Attack
&lt;/h3&gt;

&lt;p&gt;An attacker renames a malicious file (script, executable, HTML with XSS) to &lt;code&gt;report.pdf&lt;/code&gt; and uploads it. If the server trusts the extension – it will save the file, and under certain conditions may execute it or serve it to other users.&lt;/p&gt;

&lt;h3&gt;
  
  
  How We Defended
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Three layers of file type validation:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1 – Frontend (extension):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_TYPES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_EXTENSIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isPdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_TYPES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
                &lt;span class="nx"&gt;ALLOWED_EXTENSIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ext&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
                  &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isPdf&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="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PDF only&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We check both the MIME type and the extension. But frontend validation is just a UX filter, not a security measure. Anyone can send a request directly, bypassing the extension.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2 – Backend, request Content-Type:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The server routes the request by &lt;code&gt;Content-Type&lt;/code&gt;: &lt;code&gt;multipart/form-data&lt;/code&gt; for files, &lt;code&gt;application/json&lt;/code&gt; for URLs. This isn't content validation, but it's the first server-side barrier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3 – Backend, magic bytes (file signature):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;pdfMagicBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"%PDF"&lt;/span&gt;

&lt;span class="n"&gt;header4&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;pdfMagicBytes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;respondError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusBadRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrCodeValidationError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"File is not a valid PDF"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Seek&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// Reset position for further processing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the key check. Every file format starts with a specific byte sequence – "magic bytes." PDF always starts with &lt;code&gt;%PDF&lt;/code&gt;. Even if an attacker renames an &lt;code&gt;.exe&lt;/code&gt; to &lt;code&gt;.pdf&lt;/code&gt;, the first bytes will give it away.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; we validate the actual file contents, not what the browser wrote in the header. Headers are easy to forge; bytes are not (unless, of course, the attacker crafted a file that is simultaneously a valid PDF and something malicious – such polyglots do exist, more on that below).&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Attack 2: Disk Exhaustion (File Size DoS)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Attack
&lt;/h3&gt;

&lt;p&gt;An attacker uploads a gigabyte-sized file (or thousands of files in a row) to exhaust the server's disk space or memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  How We Defended
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Three layers of size limits:&lt;/strong&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%2F3low0tb5rzrwypsseu8b.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%2F3low0tb5rzrwypsseu8b.png" alt="Three layers of size limits" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The key element – &lt;code&gt;http.MaxBytesReader&lt;/code&gt; at the middleware level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxBytesReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxFileSize&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This function wraps &lt;code&gt;r.Body&lt;/code&gt; and &lt;strong&gt;stops reading&lt;/strong&gt; as soon as the limit is exceeded. The server won't read 10 GB just to say "file too large" – it will stop at the 11th megabyte.&lt;/p&gt;

&lt;p&gt;And here our device identity model kicks in at full strength. Since each browser profile is cryptographically bound to a &lt;code&gt;device_id&lt;/code&gt;, we can limit load &lt;strong&gt;per-device&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;maxJobsPerDevice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three active slots per device (a slot is occupied by tasks in &lt;code&gt;queued&lt;/code&gt;, &lt;code&gt;processing&lt;/code&gt;, &lt;code&gt;ready&lt;/code&gt;, and &lt;code&gt;error&lt;/code&gt; statuses – until auto-cleanup or manual deletion). Bypassing this by swapping cookies or tokens is impossible. To get new slots, the attacker needs to create a new browser profile, generate keys, and go through registration (which is limited to 5 attempts per IP per hour).&lt;/p&gt;




&lt;h2&gt;
  
  
  Attack 3: Path Traversal (Directory Traversal)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Attack
&lt;/h3&gt;

&lt;p&gt;An attacker sends a file named &lt;code&gt;../../../etc/passwd&lt;/code&gt; or &lt;code&gt;..\..\windows\system32\config.txt&lt;/code&gt;. If the server uses the filename for storage without sanitization, the file ends up not in the uploads folder, but in an arbitrary location on the filesystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  How We Defended
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Frontend – filename sanitization:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sanitizeFileName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[/\\&lt;/span&gt;&lt;span class="sr"&gt;?%*:|"&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// Remove dangerous characters&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;// Remove leading dots&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;// Remove trailing dots&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;substring&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="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;// Limit length&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the real defense is on the backend, where the filename is &lt;strong&gt;completely ignored&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;JobsHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;saveFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobID&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;jobDir&lt;/span&gt; &lt;span class="o"&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;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SharedDataPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jobID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MkdirAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0755&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;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"input.pdf"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// Fixed filename!&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file is always saved as &lt;code&gt;{UUID}/input.pdf&lt;/code&gt;. No user input in the path whatsoever. The UUID is generated by the server – predicting or guessing it is impossible. This is a robust defense against path traversal at the storage path level.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;But the filename doesn't end there.&lt;/strong&gt; The original filename lives on as metadata: in the database, in the UI, in the &lt;code&gt;Content-Disposition&lt;/code&gt; header when downloading results. At the storage path level, the problem is fully closed (the user-provided name is not involved). But at the output level – downloads, UI display – the name needs to be properly escaped to prevent XSS or header injection. In our case, frontend sanitization (removing special characters, limiting length) is the first barrier, while the main responsibility lies in correct escaping when serving the result.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Attack 4: SSRF (Server-Side Request Forgery)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Attack
&lt;/h3&gt;

&lt;p&gt;When uploading via URL, the attacker submits not a link to a PDF, but an internal service address: &lt;code&gt;http://169.254.169.254/latest/meta-data/&lt;/code&gt; (the metadata endpoint – identical across AWS, GCP, and other cloud providers like Yandex Cloud and VK Cloud), &lt;code&gt;http://localhost:6379/&lt;/code&gt; (Redis), or &lt;code&gt;http://192.168.1.1/admin&lt;/code&gt;. The server, trusting the URL, makes the request on its own behalf – and the attacker gains access to internal infrastructure.&lt;/p&gt;

&lt;p&gt;SSRF is part of the &lt;a href="https://owasp.org/Top10/" rel="noopener noreferrer"&gt;OWASP Top 10&lt;/a&gt; and is one of the most prevalent vulnerabilities in modern applications.&lt;/p&gt;

&lt;h3&gt;
  
  
  How We Defended
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Step 1 – IP address validation:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The idea is simple: before making an HTTP request to the user-provided URL, we resolve DNS and verify that all resulting IPs are public:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;validatePublicURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawURL&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// ... scheme and host validation&lt;/span&gt;

    &lt;span class="n"&gt;ips&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LookupIP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hostname&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;ips&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;isPrivateOrReservedIP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)&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;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"URL resolves to private/reserved IP"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;isPrivateOrReservedIP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&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;ip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsLoopback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;        &lt;span class="c"&gt;// 127.0.0.0/8, ::1&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsLinkLocalUnicast&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c"&gt;// 169.254.0.0/16 – cloud metadata&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsMulticast&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&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;ip4&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;To4&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;ip4&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ip4&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&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;ip4&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;172&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ip4&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;16&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ip4&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="m"&gt;31&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&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;ip4&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ip4&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;168&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c"&gt;// TODO: 100.64.0.0/10 (Carrier-Grade NAT)&lt;/span&gt;
        &lt;span class="c"&gt;// TODO: 198.18.0.0/15 (benchmark testing)&lt;/span&gt;
        &lt;span class="c"&gt;// TODO: 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (documentation)&lt;/span&gt;
        &lt;span class="c"&gt;// TODO: 198.51.100.0/24, 203.0.113.0/24 (documentation)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// IPv6 ULA (fc00::/7)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0xfc&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0xfd&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// TODO: ::ffff:0:0/96 (IPv4-mapped), 2001:db8::/32 (documentation)&lt;/span&gt;
    &lt;span class="c"&gt;// TODO: dial-time validation for DNS rebinding protection&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caveat:&lt;/strong&gt; this is a denylist approach – we enumerate "what to block." OWASP recommends a positive allowlist for SSRF (only allow known-safe destinations). A denylist is simpler to implement but easier to bypass: if we miss a range (e.g., &lt;code&gt;100.64.0.0/10&lt;/code&gt; – Carrier-Grade NAT or &lt;code&gt;198.18.0.0/15&lt;/code&gt; – benchmark), the request goes through. For our use case, the denylist covers the main risks, but for critical infrastructure you should move to an allowlist or dial-time validation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Step 2 – Blocking redirects:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;httpClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;CheckRedirect&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;via&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&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;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrUseLastResponse&lt;/span&gt; &lt;span class="c"&gt;// Do not follow redirects&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A classic trick: &lt;code&gt;http://safe-looking-url.com&lt;/code&gt; → 302 → &lt;code&gt;http://169.254.169.254/&lt;/code&gt;. We don't follow redirects at all. If a URL returns a 3xx response, the request is rejected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;400&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;respondError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusBadRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"URL redirects are not allowed for security reasons"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note on DNS Rebinding:&lt;/strong&gt; there's a more sophisticated attack where a DNS name first resolves to a public IP (passes validation), then resolves to a private IP (when the HTTP client actually connects). Full DNS rebinding protection requires pinning the resolved address or using a specialized HTTP client. In our case, this vector is partially mitigated by redirect blocking and the single-shot nature of the request.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Attack 5: Replay Attack (Request Reuse)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Attack
&lt;/h3&gt;

&lt;p&gt;An attacker intercepts a legitimate file upload request and sends it again – repeatedly. The result: dozens of identical tasks, quota exhaustion, server load.&lt;/p&gt;

&lt;h3&gt;
  
  
  How We Defended
&lt;/h3&gt;

&lt;p&gt;This is where the signature system we described in the "Foundation" section comes into play. Every request already contains a timestamp, nonce, and ECDSA signature – and these are precisely what make a replay attack futile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server-side checks:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Time window&lt;/strong&gt; – requests older than 5 minutes are rejected&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nonce uniqueness&lt;/strong&gt; – every nonce is stored in Redis with a 5-minute TTL. Duplicate nonce → &lt;code&gt;409 Replay Detected&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Body integrity&lt;/strong&gt; – the SHA256 hash of the body is compared against the declared value. Tampering with the body while preserving the signature is impossible&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cryptographic signature&lt;/strong&gt; – the signature covers the method, path, timestamp, nonce, and body hash. Without the private key, creating a valid signature is impossible
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Body integrity check&lt;/span&gt;
&lt;span class="n"&gt;actualHash&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sum256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bodyBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EqualFold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EncodeToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actualHash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;bodySHA256Header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;respondError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnauthorized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Body hash mismatch"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means: even if an attacker intercepts a request, they can't replay it (nonce already used), modify it (signature won't match), or stretch it in time (timestamp expired).&lt;/p&gt;




&lt;h2&gt;
  
  
  Attack 6: Device Spoofing (Identity Spoofing)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Attack
&lt;/h3&gt;

&lt;p&gt;An attacker tries to impersonate another device to use its quotas, access its results, or simply bypass rate limits.&lt;/p&gt;

&lt;h3&gt;
  
  
  How We Defended
&lt;/h3&gt;

&lt;p&gt;As we described in the "Foundation" section, device identity is based on &lt;strong&gt;asymmetric cryptography&lt;/strong&gt;, not simple tokens. This makes spoofing fundamentally impossible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The private key can't be stolen&lt;/strong&gt; – &lt;code&gt;extractable: false&lt;/code&gt; in the WebCrypto API means that even a malicious extension cannot read the key from IndexedDB. It can only be used for signing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A token without the key is useless&lt;/strong&gt; – the &lt;code&gt;device_token&lt;/code&gt; grants the right to send a request, but without an ECDSA signature from the private key, the server will reject it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Creating a "new device" is expensive&lt;/strong&gt; – requires a new browser profile + registration, limited to &lt;strong&gt;5 attempts per IP per hour&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The key is tied to the browser profile&lt;/strong&gt; – switching tabs, restarting, updating the extension – the key persists. Only deleting the profile or the extension causes the key to be lost&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Attack 7: Mass Abuse via Upload
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Attack
&lt;/h3&gt;

&lt;p&gt;Mass-submitting upload requests to overload the server – saturating the network, disk, or CPU (PDF parsing is resource-intensive). This is not a network-level DDoS (that's handled by CDN, WAF, and infrastructure solutions like Cloudflare, Qrator, DDoS-Guard, etc.), but application-level abuse – exploiting the upload business logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  How We Mitigate the Risk
&lt;/h3&gt;

&lt;p&gt;Defense in depth at the application layer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Registration&lt;/td&gt;
&lt;td&gt;Rate limit on &lt;code&gt;/register&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5 per hour per IP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requests&lt;/td&gt;
&lt;td&gt;Signature on every request&lt;/td&gt;
&lt;td&gt;No key – no access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency&lt;/td&gt;
&lt;td&gt;Per-device slots&lt;/td&gt;
&lt;td&gt;3 active slots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;MaxBytesReader&lt;/td&gt;
&lt;td&gt;11 MB per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request body&lt;/td&gt;
&lt;td&gt;Multipart part size&lt;/td&gt;
&lt;td&gt;10 MB per file&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To mass-abuse file uploads, an attacker would need to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create many devices (rate-limited registration)&lt;/li&gt;
&lt;li&gt;For each device – generate a key pair and sign every request&lt;/li&gt;
&lt;li&gt;Each device is limited to 3 active slots and 10 MB per file&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This &lt;strong&gt;raises the cost of mass abuse&lt;/strong&gt; at the application level. But it is not a substitute for network-level DDoS mitigation (Cloudflare, Qrator, DDoS-Guard, and similar solutions) – SYN floods or HTTP floods at the infrastructure level are beyond what application-level logic can handle.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;On IP-based rate limiting and CGNAT.&lt;/strong&gt; Yes, we know: thousands of users can sit behind a single mobile carrier IP. That's why the IP limit is not the primary signal, but a supplementary one, and only on the device registration endpoint. Working requests are rate-limited by &lt;code&gt;device_id&lt;/code&gt;, not by IP. If normal, legitimate traffic comes from a single mobile IP – it goes through. The limit only kicks in on an anomalous burst of registrations, characteristic of automated farming.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What Else Exists: Attacks Where Protection Is Incomplete
&lt;/h2&gt;

&lt;p&gt;Honesty is the best policy. Here are the vectors we're aware of and where our defenses are still imperfect:&lt;/p&gt;

&lt;h3&gt;
  
  
  PDF Bomb (Decompression Bomb)
&lt;/h3&gt;

&lt;p&gt;A PDF can contain compressed streams that expand to gigabytes when decompressed. A 5 MB file can become 10 GB during parsing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What already protects us:&lt;/strong&gt; the conversion engine (MinerU by default, Docling as an opt-in) runs in a &lt;strong&gt;separate Docker container&lt;/strong&gt;. The compose configuration specifies a memory limit and timeout:&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%2Fufwi06dpfhazc11vdbr0.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%2Fufwi06dpfhazc11vdbr0.png" alt="Configuration specifies a memory limit and timeout" width="800" height="576"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The separate conversion service, 15-minute timeout, and memory limit in the deploy configuration reduce the blast radius: even if a PDF bomb starts decompressing, the consequences are contained within a single container and don't affect the API, the database, or other workers. The exact OOM behavior depends on the runtime environment (Docker Desktop, Swarm, and Kubernetes apply &lt;code&gt;deploy.resources&lt;/code&gt; differently), but the isolation principle works in every case.&lt;/p&gt;

&lt;p&gt;The timeout is a second line of defense: even if memory doesn't run out but decompression drags on, the worker will forcefully terminate processing and mark the task as failed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this doesn't cover:&lt;/strong&gt; we have no &lt;em&gt;preventive&lt;/em&gt; protection – we don't analyze the compression ratio before processing begins. A PDF bomb will still start decompressing, just within an isolated environment. For full protection, you could add an input heuristic: an anomalously high compression ratio (file size vs. number of streams/pages) is a reason to reject the file before processing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Malicious PDF Content
&lt;/h3&gt;

&lt;p&gt;PDF is a full-fledged container that can contain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Embedded JavaScript&lt;/li&gt;
&lt;li&gt;Forms with auto-submit&lt;/li&gt;
&lt;li&gt;Links to external resources&lt;/li&gt;
&lt;li&gt;Embedded files of other formats&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We don't execute or render PDFs – we only extract text and structure, which substantially reduces the risk. And the engine's container isolation means that even if the parser is vulnerable to a specially crafted PDF, the consequences are confined to the container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this doesn't cover:&lt;/strong&gt; there is no antivirus scanning (ClamAV) and no CDR (Content Disarm &amp;amp; Reconstruct – rebuilding the PDF while stripping potentially dangerous elements: JavaScript, forms, embedded files). For scenarios where the conversion output is served to third parties, both the incoming PDF and the output Markdown should be checked for malicious links/scripts. A separate concern is timely updates to the parsers themselves (MinerU, Docling and their dependencies): a PDF parser is its own attack surface, and CVEs can appear regularly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage and Access to Uploaded Files
&lt;/h3&gt;

&lt;p&gt;It's worth separately noting what the OWASP File Upload Cheat Sheet lists as mandatory items, and what we've already implemented but haven't explicitly discussed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Files are stored outside the webroot&lt;/strong&gt; – uploaded PDFs reside in shared storage between the API and worker, not in a publicly accessible directory. Nginx does not serve them directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The original PDF is not exposed externally&lt;/strong&gt; – the uploaded file is not served via Nginx or a separate endpoint. Only the conversion result (Markdown) is available through the API, and the original is deleted after processing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The application does not execute uploaded files&lt;/strong&gt; – even if a non-PDF somehow gets uploaded, it won't be interpreted by the server. The upload storage is not used as a public serving directory and is not an execution point.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Polyglot Files
&lt;/h3&gt;

&lt;p&gt;A polyglot is a file that is simultaneously a valid PDF and, for example, a valid ZIP or HTML. Our magic bytes check passes (&lt;code&gt;%PDF&lt;/code&gt; at the start), but other software may interpret the file differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Current status:&lt;/strong&gt; only the first 4 bytes are checked. No deeper PDF structure analysis is performed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; validate the PDF structure (xref table, trailer) or use specialized libraries for deep validation.&lt;/p&gt;

&lt;h3&gt;
  
  
  DNS Rebinding
&lt;/h3&gt;

&lt;p&gt;As mentioned in the SSRF section – there's a time gap (TOCTOU) between the IP check and the actual HTTP request. An attacker could theoretically bypass &lt;code&gt;validatePublicURL&lt;/code&gt; via a controlled DNS server: return a public IP on the first resolve (passes validation), then return a private IP on the second (when the HTTP client establishes the connection).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What already protects us:&lt;/strong&gt; even with successful DNS rebinding, the attacker only gets &lt;strong&gt;blind SSRF&lt;/strong&gt; – the server can "touch" the internal address, but the response won't be returned. The reason: the response from an internal service (JSON from the cloud metadata API, text from Redis, HTML from an admin panel) won't pass the magic bytes check (&lt;code&gt;%PDF&lt;/code&gt;), and the request will be rejected with a generic message &lt;code&gt;"URL does not point to a valid PDF file"&lt;/code&gt;. No data from the response is returned to the user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this doesn't cover:&lt;/strong&gt; blind SSRF still allows "reaching" internal services with a GET request. For critical infrastructure, this can be undesirable – for example, cloud provider metadata APIs (AWS, GCP, as well as Yandex Cloud, VK Cloud, and others) may return IAM tokens via GET without authorization. Full protection means moving IP validation to the TCP connection level (dial-time validation) to eliminate the TOCTOU gap between DNS resolution and connection establishment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary Table: Attacks and Protection Status
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attack&lt;/th&gt;
&lt;th&gt;Severity&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;How We're Protected&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File type spoofing&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Protected&lt;/td&gt;
&lt;td&gt;MIME + extension + magic bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk exhaustion&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Protected&lt;/td&gt;
&lt;td&gt;MaxBytesReader + multipart limit + slots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Path Traversal&lt;/td&gt;
&lt;td&gt;Critical&lt;/td&gt;
&lt;td&gt;Protected&lt;/td&gt;
&lt;td&gt;Fixed filename &lt;code&gt;input.pdf&lt;/code&gt; + UUID directories&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSRF&lt;/td&gt;
&lt;td&gt;Critical&lt;/td&gt;
&lt;td&gt;Risk reduced&lt;/td&gt;
&lt;td&gt;IP denylist + redirect blocking (not allowlist, no dial-time validation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replay attack&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Protected&lt;/td&gt;
&lt;td&gt;Nonce + timestamp + ECDSA signature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Device spoofing&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Protected&lt;/td&gt;
&lt;td&gt;Asymmetric cryptography (P-256)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application-level abuse&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Risk reduced&lt;/td&gt;
&lt;td&gt;Rate limit + signature + slots (not a substitute for network DDoS mitigation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDF bomb&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Container isolation + OOM + 15 min timeout, no preventive analysis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Malicious PDF&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;No rendering + container isolation, no antivirus&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polyglot files&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Magic bytes only, no deep analysis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS Rebinding&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Blind SSRF only – magic bytes block data leakage, no dial-time validation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Approach Scales
&lt;/h2&gt;

&lt;p&gt;Everything described above was demonstrated using a PDF-to-Markdown converter, but the same set of principles was applied in another UGC project involving image uploads. There, instead of magic bytes &lt;code&gt;%PDF&lt;/code&gt; – &lt;code&gt;image.DecodeConfig()&lt;/code&gt; in Go (effectively the same magic bytes via the standard library); instead of container isolation against PDF bombs – a pixel limit on input (reject before decoding into memory); and instead of a fixed &lt;code&gt;input.pdf&lt;/code&gt; – server-generated UUIDs at every path level. Additionally, re-encoding is in play: every uploaded image is decoded into a pixel buffer and re-encoded – EXIF, GPS, embedded scripts are destroyed, polyglot files are neutralized. The specifics change (PDF vs. images, extension vs. web app), but the foundation is the same.&lt;/p&gt;




&lt;h2&gt;
  
  
  Principles Worth Taking With You
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Security is not "later."&lt;/strong&gt; If you're using an LLM to generate file upload code – review the result against the checklist from this article. "I'll add validation later" is technical debt that will bite first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never trust the frontend.&lt;/strong&gt; Any client-side validation is UX, not security. &lt;code&gt;accept=".pdf"&lt;/code&gt; on &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt; filters accidental mistakes, not targeted attacks. All real protection is on the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate content, not metadata.&lt;/strong&gt; The file extension and Content-Type are "what the file calls itself." Magic bytes are "what the file actually is."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't use user-provided filenames in storage paths.&lt;/strong&gt; UUID + fixed name closes the storage-path aspect of path traversal. But the filename as metadata (database, UI, &lt;code&gt;Content-Disposition&lt;/code&gt;) still needs to be validated and escaped separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When uploading by URL, validate the IP before the request.&lt;/strong&gt; SSRF is an attack you can't filter after the request has already been sent. Resolve DNS, check the ranges, block redirects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the attack economically unattractive.&lt;/strong&gt; Absolute protection doesn't exist – a motivated attacker will find a way. But you can make the cost of the attack vastly exceed the potential payoff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be honest about the gaps.&lt;/strong&gt; Security is a process, not a state. Knowing your weak spots and planning to close them is better than believing you're invulnerable.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;This case study comes from building &lt;a href="https://pdf2md.dev/" rel="noopener noreferrer"&gt;pdf2md.dev&lt;/a&gt;: convert a PDF to clean, LLM-ready Markdown from the &lt;a href="https://pdf2md.dev/app/" rel="noopener noreferrer"&gt;web app&lt;/a&gt; (no signup, files auto-deleted), or as a &lt;a href="https://pdf2md.dev/developers/" rel="noopener noreferrer"&gt;REST API and hosted MCP&lt;/a&gt; for agents and RAG pipelines. The &lt;a href="https://pdf2md.dev/privacy/" rel="noopener noreferrer"&gt;privacy notice&lt;/a&gt; states retention and training policy in plain language.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>go</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>What actually breaks when you turn PDFs into Markdown</title>
      <dc:creator>Dmitry Petrakov</dc:creator>
      <pubDate>Mon, 29 Jun 2026 12:05:08 +0000</pubDate>
      <link>https://dev.to/dimlight/what-actually-breaks-when-you-turn-pdfs-into-markdown-148h</link>
      <guid>https://dev.to/dimlight/what-actually-breaks-when-you-turn-pdfs-into-markdown-148h</guid>
      <description>&lt;p&gt;"Convert a PDF to Markdown" sounds like a solved problem. Take the text out, turn headings into &lt;code&gt;#&lt;/code&gt;, turn tables into pipes, done.&lt;/p&gt;

&lt;p&gt;After building a converter for it, I have a less satisfying answer: the easy cases are easy, and the hard cases are not edge cases. They are the documents people actually care about – research papers, annual reports, invoices, scanned contracts, specs, and the table-heavy PDFs someone wants to feed into an LLM.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Disclosure: I build &lt;a href="https://pdf2md.dev/" rel="noopener noreferrer"&gt;pdf2md.dev&lt;/a&gt;, so I have skin in this game. This is not a benchmark claiming "we're the best." It is a breakdown of the failure modes I had to handle and the trade-offs I made – written so it's useful even if you never touch my tool and just want to evaluate your own.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A PDF is not a document
&lt;/h2&gt;

&lt;p&gt;The core problem is that a PDF does not usually contain "a document" the way Markdown, HTML, or DOCX does.&lt;/p&gt;

&lt;p&gt;It contains drawing instructions: put these glyphs at these coordinates, draw this line here, place this image there. The structure you see as a reader is reconstructed by your eyes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;that larger bold text is &lt;em&gt;probably&lt;/em&gt; a heading&lt;/li&gt;
&lt;li&gt;those aligned numbers are &lt;em&gt;probably&lt;/em&gt; a table&lt;/li&gt;
&lt;li&gt;that block on the left should be read before the block on the right&lt;/li&gt;
&lt;li&gt;that superscript belongs to a formula&lt;/li&gt;
&lt;li&gt;that scanned page has no text layer at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A converter has to rebuild all of that from layout, geometry, OCR, and heuristics. If it only "extracts text," it will work on the demo PDF and fall apart on the first real report.&lt;/p&gt;

&lt;p&gt;Here are the main things that break, roughly ordered by how often they bite.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Tables are not one problem
&lt;/h2&gt;

&lt;p&gt;Simple tables are fine. If the PDF has a clean grid and each cell maps to one row and one column, Markdown is a good target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| Quarter | Revenue | Growth |
|---------|---------|--------|
| Q1      | $1.2M   | +8%    |
| Q2      | $1.4M   | +17%   |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Renders to:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Quarter&lt;/th&gt;
&lt;th&gt;Revenue&lt;/th&gt;
&lt;th&gt;Growth&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Q1&lt;/td&gt;
&lt;td&gt;$1.2M&lt;/td&gt;
&lt;td&gt;+8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q2&lt;/td&gt;
&lt;td&gt;$1.4M&lt;/td&gt;
&lt;td&gt;+17%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trouble starts when the table stops being a simple grid.&lt;/p&gt;

&lt;p&gt;Merged cells do not map cleanly to GitHub-flavored Markdown. Nested headers have a hierarchy Markdown tables cannot represent. Rotated tables add a reading-order problem before you even get to cell detection. Borderless tables are worst of all, because the grid exists only as alignment.&lt;/p&gt;

&lt;p&gt;This is where converters quietly get dishonest. They return a table that &lt;em&gt;looks&lt;/em&gt; tidy but has shifted columns, duplicated headers, or numbers attached to the wrong labels. That is more dangerous than an obvious failure – especially if the output flows into an LLM or a RAG index, where nobody re-reads it.&lt;/p&gt;

&lt;p&gt;My rule for this class of problem: preserve as much structure as the target format can honestly express, and don't pretend Markdown can encode everything a PDF table visually implies. Straight grids come out ready to use. Complex financial or scientific tables may still need a visual check. That is less magical, but it is the difference between saving time and silently corrupting data.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Reading order is layout analysis, not text extraction
&lt;/h2&gt;

&lt;p&gt;Academic papers, magazines, datasheets, and many reports use two or three columns. A naive extractor reads across the page by x/y position and produces nonsense:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;First line of column one first line of column two
second line of column one second line of column two
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The right behavior is to detect column boundaries, read each column top-to-bottom, then move on. That requires layout analysis – the text stream alone is not enough.&lt;/p&gt;

&lt;p&gt;The same problem hits sidebars, captions, footnotes, running headers, and page numbers. A human ignores a repeated header automatically; a converter has to decide whether those fragments are content, metadata, or noise. Get it wrong and you don't just produce ugly Markdown – you change the meaning.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Formulas are reconstructed, not copied
&lt;/h2&gt;

&lt;p&gt;Mathematical notation is a layout problem too. In a PDF, a formula is a set of glyphs placed carefully on the page: &lt;code&gt;∑&lt;/code&gt;, &lt;code&gt;√&lt;/code&gt;, superscripts, subscripts, fraction bars, Greek letters, spacing. Turning that back into something usable means producing LaTeX-like text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;$$
&lt;span class="se"&gt;\s&lt;/span&gt;um_{i=1}^{n} x_i
$$
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Renders to:&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%2Fepjzkv4cymxousapwh17.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%2Fepjzkv4cymxousapwh17.png" alt="Rendered formula: the sum from i=1 to n of x sub i" width="239" height="166"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the converter only sees characters in approximate order, an equation becomes a line of floating symbols – useless for documentation, search, or LLM context. This is why I don't trust regex-only PDF pipelines for technical documents. They're fine for plain text; formulas need the converter to understand visual structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Scanned PDFs change the entire pipeline
&lt;/h2&gt;

&lt;p&gt;A scanned PDF may have no embedded text at all – it's just images of pages. Now the problem is OCR, with its own failure modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;scan quality dominates everything&lt;/li&gt;
&lt;li&gt;skewed or low-contrast pages hurt recognition&lt;/li&gt;
&lt;li&gt;tiny text and dense tables are slow&lt;/li&gt;
&lt;li&gt;handwriting is not reliably recognized&lt;/li&gt;
&lt;li&gt;OCR produces &lt;em&gt;plausible-looking&lt;/em&gt; mistakes, which are the worst kind&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For printed or typeset text, good scans convert well. A sharp 300 DPI page with high contrast is a completely different input from a crooked phone photo of a faded fax.&lt;/p&gt;

&lt;p&gt;There's also a product decision every converter has to make: &lt;strong&gt;what happens when a long scan exceeds the processing budget?&lt;/strong&gt; Failing the whole job is simple to implement and a terrible experience. The behavior I chose is to return the Markdown produced within the budget and mark the job as &lt;strong&gt;truncated&lt;/strong&gt; – a partial result with an explicit signal, instead of losing everything. The signal is the important part. A partial result &lt;em&gt;without&lt;/em&gt; a truncation marker is just another form of silent data loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Images are either content or noise
&lt;/h2&gt;

&lt;p&gt;Images in PDFs are ambiguous. Sometimes they're essential – diagrams, charts, stamps, signatures. Sometimes they're decorative backgrounds. Sometimes the whole page is an image but the user wants text, not embedded base64.&lt;/p&gt;

&lt;p&gt;So "include images" is not one setting. The practical version is three different intents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;embed&lt;/strong&gt; images when the Markdown should be self-contained&lt;/li&gt;
&lt;li&gt;use &lt;strong&gt;placeholders&lt;/strong&gt; when the user wants clean text output&lt;/li&gt;
&lt;li&gt;run &lt;strong&gt;OCR&lt;/strong&gt; on scanned pages when text needs to be recovered&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no universal best choice. A Markdown file headed for a knowledge base, an LLM prompt, or a legal archive each wants different output.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The converter itself can fail before the Markdown does
&lt;/h2&gt;

&lt;p&gt;The visible part of a converter is the Markdown. The part that decides whether you can trust it is job reliability, and those failures are boring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a conversion hangs forever&lt;/li&gt;
&lt;li&gt;a heavy OCR job runs out of memory&lt;/li&gt;
&lt;li&gt;a worker dies halfway through&lt;/li&gt;
&lt;li&gt;a job gets retried too aggressively&lt;/li&gt;
&lt;li&gt;one large file blocks everyone else&lt;/li&gt;
&lt;li&gt;the user closes the tab before the result is ready&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where a weekend script and a service diverge. My implementation ended up with a real job lifecycle:&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%2F3crig8g2weq7m96l9hhb.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%2F3crig8g2weq7m96l9hhb.png" alt="Conversion job lifecycle: queued to processing, then ready, canceled or error" width="799" height="262"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The system tracks each job, retries bounded failures, applies time budgets, deletes input files after processing, and keeps results only for a short retention window. Those limits are not glamorous, but they &lt;em&gt;are&lt;/em&gt; part of trust. A converter that accepts anything, promises instant results, and never explains retention isn't more user-friendly – it's just hiding the operational reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two engines instead of one
&lt;/h2&gt;

&lt;p&gt;There is no single engine that wins on every document, so I run two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MinerU&lt;/strong&gt; is the default. It holds up better on dense documents, heavy OCR, Cyrillic content, and table-heavy scans, and it's the safer choice under memory pressure. &lt;strong&gt;Docling&lt;/strong&gt; is an opt-in: faster and cleaner on simple, well-structured text PDFs, but less forgiving on heavy full-OCR workloads.&lt;/p&gt;

&lt;p&gt;So the question isn't "which engine is best?" – it's "which engine is best for &lt;em&gt;this&lt;/em&gt; document?" That's an unsatisfying marketing answer and a useful engineering one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd evaluate any PDF-to-Markdown tool
&lt;/h2&gt;

&lt;p&gt;If you're picking a converter, mine or anyone's, don't start with the landing page. Test it with documents that expose different failure modes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;a simple text PDF with headings and lists&lt;/li&gt;
&lt;li&gt;a two-column paper with footnotes&lt;/li&gt;
&lt;li&gt;a table with merged headers&lt;/li&gt;
&lt;li&gt;a scanned invoice or contract&lt;/li&gt;
&lt;li&gt;a technical paper with formulas&lt;/li&gt;
&lt;li&gt;a document with screenshots or diagrams&lt;/li&gt;
&lt;li&gt;a long PDF that might hit a time budget&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;As you read the output, the useful questions split in two. First, did the structure survive: does the reading order match the original, are the tables actually correct rather than merely tidy, do the formulas come back usable, and does the OCR admit when it can't read handwriting instead of inventing words? Check for the truncation marker too, because a partial result that isn't labelled as partial is a quiet failure.&lt;/p&gt;

&lt;p&gt;The second group is the one people skip, and it's the one that matters most: can you find the file-size limit, the retention window and the privacy policy without digging, can you delete a job yourself, and does the tool &lt;em&gt;explain&lt;/em&gt; its limits instead of hiding them? PDF conversion usually touches private documents, so a converter has to earn trust before output quality even comes up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy, in plain language
&lt;/h2&gt;

&lt;p&gt;Here's the model I wanted, stated the way I think every converter should state it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you can convert without creating an account&lt;/li&gt;
&lt;li&gt;uploaded PDFs are deleted after processing&lt;/li&gt;
&lt;li&gt;results are kept only for a short download window, then removed automatically&lt;/li&gt;
&lt;li&gt;you can delete a job manually&lt;/li&gt;
&lt;li&gt;documents are never used to train models&lt;/li&gt;
&lt;li&gt;documents are not sold or used for advertising&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full &lt;a href="https://pdf2md.dev/privacy/" rel="noopener noreferrer"&gt;privacy notice&lt;/a&gt; spells it out, and the &lt;a href="https://pdf2md.dev/developers/" rel="noopener noreferrer"&gt;developer docs&lt;/a&gt; cover the API and a hosted MCP endpoint for agent workflows. I'm putting this in the article because retention and training policy are product features when you're asking people to upload contracts and reports, not fine print to bury three clicks deep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try your worst PDF
&lt;/h2&gt;

&lt;p&gt;The best test isn't a clean sample document. It's the PDF that already broke your previous workflow – a dense financial table, a two-column paper full of formulas, a long scanned report, something you want to feed into an LLM without losing structure.&lt;/p&gt;

&lt;p&gt;Throw it at &lt;a href="https://pdf2md.dev/app/" rel="noopener noreferrer"&gt;the web app&lt;/a&gt; and see how far the honest 90% gets you. No signup, files auto-deleted.&lt;/p&gt;

&lt;p&gt;If it works, great. If it breaks, I genuinely want to know which document exposed the failure – &lt;strong&gt;drop the kind of PDF you're fighting in the comments.&lt;/strong&gt; The last 20% of this problem isn't one bug; it's a long list of document-specific edge cases, and real examples are how converters get better.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written from first-hand work on the project; I used an AI assistant to tighten the structure, not to invent the technical claims.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>ai</category>
      <category>markdown</category>
    </item>
  </channel>
</rss>
