DEV Community

Cover image for Secure File Upload in Go: 7 Attacks and How We Mitigated Them
Dmitry Petrakov
Dmitry Petrakov

Posted on • Originally published at pdf2md.dev

Secure File Upload in Go: 7 Attacks and How We Mitigated Them

"Build a PDF upload form" – sounds like a 30-minute task. Claude/GPT will write the handler, we'll add accept=".pdf" on the frontend, multer on the backend – and we've got a working upload. Ship it.

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.

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.

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.

Disclaimer. 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 PDF to Markdown. 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.

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

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

Summary table with statuses – at the end of the article.


Upload Architecture: What Happens When You Send a File

Before talking about attacks, let's look at the file's journey through the system:

File's journey through the system

Two upload channels:

  • Direct upload – the user selects a file or drags and drops it
  • URL upload – the user provides a link to a PDF

Each channel carries its own set of threats, and each requires its own defenses.

But before we dig into specific attacks, we need to understand the foundation that our entire security model rests on.

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.


Foundation: Anonymous Device Identity

Why so complex? 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.

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

We solved this through cryptographic device identity – essentially, each browser profile becomes an anonymous yet verifiable "account."

Important: this is not user tracking or covert identity fingerprinting. The device_id 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 this same browser profile has made requests before. Incognito window = new device. Extension uninstalled = keys lost forever. At the auth-model level, device_id 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.

How It Works

On first launch, the extension generates an ECDSA P-256 key pair via the WebCrypto API:

const keyPair = await crypto.subtle.generateKey(
  { name: 'ECDSA', namedCurve: 'P-256' },
  false,  // extractable = false – the key cannot be exported!
  ['sign', 'verify']
);
Enter fullscreen mode Exit fullscreen mode

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

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

Header Purpose
Authorization: Bearer <token> Device identification
X-Timestamp Request time (±5 min window)
X-Nonce Unique request ID (anti-replay)
X-Body-SHA256 Body hash – data integrity
X-Signature ECDSA signature of all the above

The signature covers: METHOD + PATH + TIMESTAMP + NONCE + BODY_HASH. Forging it without the private key is impossible.

Signature ≠ encryption. 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.

The server verifies the signature using the public key bound to the device_id. If the signature doesn't match – the request is rejected, regardless of whether the token is valid.

Why This Matters for File Upload

This model gives us something you normally don't get without accounts:

  • Per-device control – we can limit the number of concurrent tasks, files per day, and requests per minute for each device (i.e., browser profile)
  • Cost of creating a "new account" – 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 5 attempts per IP per hour
  • Protection against token theft – even if the device_token leaks, it's useless without the private key (which can't be exported)
  • Request integrity – the request body (including the file) is covered by the signature via a SHA256 hash. Tampering with the file in transit is impossible

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.

Is this protection absolute? 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 significantly raise the barrier to entry. 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.


Attack 1: File Type Spoofing (Malicious File Upload)

The Attack

An attacker renames a malicious file (script, executable, HTML with XSS) to report.pdf 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.

How We Defended

Three layers of file type validation:

Layer 1 – Frontend (extension):

const ALLOWED_TYPES = ['application/pdf'];
const ALLOWED_EXTENSIONS = ['.pdf'];

function validateFile(file) {
  const isPdf = ALLOWED_TYPES.includes(file.type) ||
                ALLOWED_EXTENSIONS.some(ext =>
                  file.name.toLowerCase().endsWith(ext));

  if (!isPdf) return { valid: false, error: 'PDF only' };
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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.

Layer 2 – Backend, request Content-Type:

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

Layer 3 – Backend, magic bytes (file signature):

pdfMagicBytes = "%PDF"

header4 := make([]byte, 4)
_, err = file.Read(header4)
if string(header4) != pdfMagicBytes {
    respondError(w, http.StatusBadRequest,
        models.ErrCodeValidationError,
        "File is not a valid PDF")
}
file.Seek(0, 0) // Reset position for further processing
Enter fullscreen mode Exit fullscreen mode

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

Important: 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).


Attack 2: Disk Exhaustion (File Size DoS)

The Attack

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

How We Defended

Three layers of size limits:

Three layers of size limits

The key element – http.MaxBytesReader at the middleware level:

r.Body = http.MaxBytesReader(w, r.Body, h.cfg.Storage.MaxFileSize + 1024*1024)
Enter fullscreen mode Exit fullscreen mode

This function wraps r.Body and stops reading 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.

And here our device identity model kicks in at full strength. Since each browser profile is cryptographically bound to a device_id, we can limit load per-device:

const maxJobsPerDevice = 3
Enter fullscreen mode Exit fullscreen mode

Three active slots per device (a slot is occupied by tasks in queued, processing, ready, and error 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).


Attack 3: Path Traversal (Directory Traversal)

The Attack

An attacker sends a file named ../../../etc/passwd or ..\..\windows\system32\config.txt. 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.

How We Defended

Frontend – filename sanitization:

function sanitizeFileName(name) {
  return name
    .replace(/[/\\?%*:|"<>]/g, '-')  // Remove dangerous characters
    .replace(/^\.+/, '')              // Remove leading dots
    .replace(/\.+$/, '')              // Remove trailing dots
    .substring(0, 255)                // Limit length
    .trim();
}
Enter fullscreen mode Exit fullscreen mode

But the real defense is on the backend, where the filename is completely ignored:

func (h *JobsHandler) saveFile(jobID uuid.UUID, file io.Reader) error {
    jobDir := filepath.Join(h.cfg.Storage.SharedDataPath, jobID.String())
    os.MkdirAll(jobDir, 0755)

    filePath := filepath.Join(jobDir, "input.pdf") // Fixed filename!
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The file is always saved as {UUID}/input.pdf. 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.

But the filename doesn't end there. The original filename lives on as metadata: in the database, in the UI, in the Content-Disposition 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.


Attack 4: SSRF (Server-Side Request Forgery)

The Attack

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

SSRF is part of the OWASP Top 10 and is one of the most prevalent vulnerabilities in modern applications.

How We Defended

Step 1 – IP address validation:

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:

func validatePublicURL(rawURL string) error {
    u, err := url.Parse(rawURL)
    // ... scheme and host validation

    ips, err := net.LookupIP(u.Hostname())

    for _, ip := range ips {
        if isPrivateOrReservedIP(ip) {
            return fmt.Errorf("URL resolves to private/reserved IP")
        }
    }
    return nil
}

func isPrivateOrReservedIP(ip net.IP) bool {
    if ip.IsLoopback() { return true }        // 127.0.0.0/8, ::1
    if ip.IsLinkLocalUnicast() { return true } // 169.254.0.0/16 – cloud metadata
    if ip.IsMulticast() { return true }

    if ip4 := ip.To4(); ip4 != nil {
        // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
        if ip4[0] == 10 { return true }
        if ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31 { return true }
        if ip4[0] == 192 && ip4[1] == 168 { return true }

        // TODO: 100.64.0.0/10 (Carrier-Grade NAT)
        // TODO: 198.18.0.0/15 (benchmark testing)
        // TODO: 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (documentation)
        // TODO: 198.51.100.0/24, 203.0.113.0/24 (documentation)
    }

    // IPv6 ULA (fc00::/7)
    if ip[0] == 0xfc || ip[0] == 0xfd { return true }
    // TODO: ::ffff:0:0/96 (IPv4-mapped), 2001:db8::/32 (documentation)
    // TODO: dial-time validation for DNS rebinding protection

    return false
}
Enter fullscreen mode Exit fullscreen mode

Caveat: 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., 100.64.0.0/10 – Carrier-Grade NAT or 198.18.0.0/15 – 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.

Step 2 – Blocking redirects:

httpClient = &http.Client{
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        return http.ErrUseLastResponse // Do not follow redirects
    },
}
Enter fullscreen mode Exit fullscreen mode

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

if resp.StatusCode >= 300 && resp.StatusCode < 400 {
    respondError(w, http.StatusBadRequest, ...,
        "URL redirects are not allowed for security reasons")
}
Enter fullscreen mode Exit fullscreen mode

A note on DNS Rebinding: 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.


Attack 5: Replay Attack (Request Reuse)

The Attack

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

How We Defended

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.

Server-side checks:

  1. Time window – requests older than 5 minutes are rejected
  2. Nonce uniqueness – every nonce is stored in Redis with a 5-minute TTL. Duplicate nonce → 409 Replay Detected
  3. Body integrity – the SHA256 hash of the body is compared against the declared value. Tampering with the body while preserving the signature is impossible
  4. Cryptographic signature – the signature covers the method, path, timestamp, nonce, and body hash. Without the private key, creating a valid signature is impossible
// Body integrity check
actualHash := sha256.Sum256(bodyBytes)
if !strings.EqualFold(hex.EncodeToString(actualHash[:]), bodySHA256Header) {
    respondError(w, http.StatusUnauthorized, ..., "Body hash mismatch")
}
Enter fullscreen mode Exit fullscreen mode

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).


Attack 6: Device Spoofing (Identity Spoofing)

The Attack

An attacker tries to impersonate another device to use its quotas, access its results, or simply bypass rate limits.

How We Defended

As we described in the "Foundation" section, device identity is based on asymmetric cryptography, not simple tokens. This makes spoofing fundamentally impossible:

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

Attack 7: Mass Abuse via Upload

The Attack

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.

How We Mitigate the Risk

Defense in depth at the application layer:

Layer Mechanism Limit
Registration Rate limit on /register 5 per hour per IP
Requests Signature on every request No key – no access
Concurrency Per-device slots 3 active slots
Size MaxBytesReader 11 MB per request
Request body Multipart part size 10 MB per file

To mass-abuse file uploads, an attacker would need to:

  1. Create many devices (rate-limited registration)
  2. For each device – generate a key pair and sign every request
  3. Each device is limited to 3 active slots and 10 MB per file

This raises the cost of mass abuse 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.

On IP-based rate limiting and CGNAT. 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 device_id, 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.


What Else Exists: Attacks Where Protection Is Incomplete

Honesty is the best policy. Here are the vectors we're aware of and where our defenses are still imperfect:

PDF Bomb (Decompression Bomb)

A PDF can contain compressed streams that expand to gigabytes when decompressed. A 5 MB file can become 10 GB during parsing.

What already protects us: the conversion engine (MinerU by default, Docling as an opt-in) runs in a separate Docker container. The compose configuration specifies a memory limit and timeout:

Configuration specifies a memory limit and timeout

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 deploy.resources differently), but the isolation principle works in every case.

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.

What this doesn't cover: we have no preventive 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.

Malicious PDF Content

PDF is a full-fledged container that can contain:

  • Embedded JavaScript
  • Forms with auto-submit
  • Links to external resources
  • Embedded files of other formats

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.

What this doesn't cover: there is no antivirus scanning (ClamAV) and no CDR (Content Disarm & 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.

Storage and Access to Uploaded Files

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:

  • Files are stored outside the webroot – uploaded PDFs reside in shared storage between the API and worker, not in a publicly accessible directory. Nginx does not serve them directly.
  • The original PDF is not exposed externally – 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.
  • The application does not execute uploaded files – 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.

Polyglot Files

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

Current status: only the first 4 bytes are checked. No deeper PDF structure analysis is performed.

Solution: validate the PDF structure (xref table, trailer) or use specialized libraries for deep validation.

DNS Rebinding

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 validatePublicURL 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).

What already protects us: even with successful DNS rebinding, the attacker only gets blind SSRF – 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 (%PDF), and the request will be rejected with a generic message "URL does not point to a valid PDF file". No data from the response is returned to the user.

What this doesn't cover: 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.


Summary Table: Attacks and Protection Status

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

The Approach Scales

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 %PDFimage.DecodeConfig() 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 input.pdf – 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.


Principles Worth Taking With You

  1. Security is not "later." 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.
  2. Never trust the frontend. Any client-side validation is UX, not security. accept=".pdf" on <input> filters accidental mistakes, not targeted attacks. All real protection is on the server.
  3. Validate content, not metadata. The file extension and Content-Type are "what the file calls itself." Magic bytes are "what the file actually is."
  4. Don't use user-provided filenames in storage paths. UUID + fixed name closes the storage-path aspect of path traversal. But the filename as metadata (database, UI, Content-Disposition) still needs to be validated and escaped separately.
  5. When uploading by URL, validate the IP before the request. SSRF is an attack you can't filter after the request has already been sent. Resolve DNS, check the ranges, block redirects.
  6. Make the attack economically unattractive. 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.
  7. Be honest about the gaps. Security is a process, not a state. Knowing your weak spots and planning to close them is better than believing you're invulnerable.

This case study comes from building pdf2md.dev: convert a PDF to clean, LLM-ready Markdown from the web app (no signup, files auto-deleted), or as a REST API and hosted MCP for agents and RAG pipelines. The privacy notice states retention and training policy in plain language.

Top comments (0)