Bypassing Image Anti-Hotlinking with Nginx Reverse Proxy

15 JUNE PM 04:04
⚠️ Usage Warning

This technique should only be used for legitimate aggregation of publicly available RSS content. Do not use it for bypassing paywalls, accessing private content, or commercial redistribution.

When setting up my Glance dashboard to display feeds from various content platforms, I ran into a frustrating problem: images weren’t loading. Instead of the expected thumbnails and cover images that make a dashboard visually engaging, I was seeing broken image placeholders scattered throughout my feeds.


The Problem

Opening the browser’s developer console revealed the culprit immediately. Error messages like this were appearing repeatedly:

GET https://cdn.example.com/images/photo.jpg 403 (Forbidden)
Failed to load resource: the server responded with a status of 403 (Forbidden)

These 403 Forbidden errors were blocking external images from RSS feeds, yet the same URLs worked when opened directly in the browser. The image servers’ referrer policy of strict-origin-when-cross-origin was causing them to reject requests coming from my domain, triggering anti-hotlinking protection.

Understanding the Root Cause

The problem stemmed from how content platforms protect their images from unauthorized use. Most platforms implement anti-hotlinking protection by checking the Referer header in image requests. Here’s what was happening:

  1. Referrer checking: When my dashboard requested an image, the CDN server would examine the Referer header.
  2. Domain mismatch: Since requests came from my dashboard’s domain rather than the platform’s own website, the server treated them as unauthorized.
  3. Inconsistent blocking: Different CDN subdomains had varying levels of protection, explaining why some images loaded while others didn’t.

This was particularly maddening because the same images would load perfectly when I opened them directly in a new browser tab, but they’d fail when embedded in my dashboard. The RSS feeds themselves parsed correctly with all the text content intact, but without the visual elements, my dashboard looked broken.


Cloudflare Workers: From Perfect Solution to Dead End

My first instinct was to reach for Cloudflare Workers—it used to be the perfect solution for this kind of proxy problem. I could write a simple function to intercept image requests, fetch them with the proper headers, and return them with CORS headers enabled. The edge computing model would even provide great performance with global distribution.

However, when I checked Cloudflare’s current Terms of Service (last updated December 3, 2024), I discovered a clause in section 2.2.1 that stopped me in my tracks:

“(j) use the Services to provide a virtual private network or other similar proxy services.”

This restriction effectively rules out using Workers for image proxying, as it would fall under “similar proxy services.” While this clause may have existed before, Cloudflare has been increasingly strict about enforcing it.

The risk simply wasn’t worth it. Getting my account suspended would affect not just this dashboard project, but potentially other services I was running on the Cloudflare. I needed a solution that was both effective and compliant with current service terms.


Self-Hosted Nginx Reverse Proxy: The Solution

Since I was already running Glance on my own VPS, I decided to implement a reverse proxy using Nginx on the same server. This approach would give me complete control while avoiding any third-party terms of service violations. The beauty of this solution is that it uses my existing infrastructure without requiring additional hosting costs.

Setting Up Server-Side Caching

I added a server-side cache to the Nginx configuration to improve performance and reduce load on the external CDNs. This cache stores images for 14 days (336 hours), ensuring that frequently viewed images load instantly on subsequent visits.

# /etc/nginx/nginx.conf
http {
    ##
    # Cache Settings
    ##
    proxy_cache_path /var/cache/nginx/images
                     levels=1:2
                     keys_zone=images:10m
                     max_size=1g
                     inactive=336h
                     use_temp_path=off;

    # ... rest of http config
    include /etc/nginx/sites-enabled/*;
}

Configuring the Image Proxy

Then I configured the image proxy in my site configuration. The key insight was using dynamic DNS resolution to handle multiple CDN providers through a single proxy path. Here’s the configuration I added to my existing server block:

# /etc/nginx/sites-available/dashboard.example.com
server {
    server_name dashboard.example.com;

    # DNS resolver for dynamic proxy
    resolver 127.0.0.53 valid=300s;
    resolver_timeout 5s;

    # Existing Glance application
    location / {
        proxy_pass http://localhost:8080;
        # ... existing headers
    }

    # Image proxy for external CDNs
    location ~ ^/img-proxy/([^/]+)/(.*)$ {
        set $backend_host $1;
        set $backend_path $2;

        # Enable proxy caching
        proxy_cache images;
        proxy_cache_valid 200 336h;
        proxy_cache_key "$backend_host$backend_path";
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;

        # Proxy to external CDN
        proxy_pass https://$backend_host/$backend_path;

        # Essential headers for bypassing anti-hotlinking
        proxy_set_header Referer "https://$backend_host/";
        proxy_set_header User-Agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
        proxy_set_header Host $backend_host;

        # SSL configuration
        proxy_ssl_verify off;
        proxy_ssl_server_name on;

        # Add CORS and cache headers
        add_header Access-Control-Allow-Origin "*" always;
        add_header Cache-Control "public, max-age=1209600" always;
        add_header X-Cache-Status $upstream_cache_status always;

        # Basic protection against abuse
        valid_referers none blocked dashboard.example.com;
        if ($invalid_referer) {
            return 403;
        }
    }

    # ... existing SSL configuration
}

The magic happens in the regex location block. By capturing the hostname and path in separate variables, I could support any external CDN domain through my proxy. The URL transformation is clean:

Original:  https://cdn.platform.com/images/photo.jpg
Proxied:   https://dashboard.example.com/img-proxy/cdn.platform.com/images/photo.jpg

After setting up the configuration, I created the cache directory:

sudo mkdir -p /var/cache/nginx/images
sudo chown -R www-data:www-data /var/cache/nginx/images
sudo chmod -R 755 /var/cache/nginx/images
sudo nginx -t && sudo systemctl reload nginx

Automating Image URL Conversion

With the Nginx proxy in place, I needed a way to automatically convert external image URLs in my Glance dashboard. Rather than manually updating every RSS feed configuration, I wrote a client-side script that would handle the conversion transparently.

I added this JavaScript to my Glance configuration as a custom .js file:

(function () {
  // CDN domains that commonly implement anti-hotlinking
  const BLOCKED_DOMAINS = ["cdn.example.com", "images.platform.com", "static.service.net"];

  function convertExternalImages() {
    const selector = BLOCKED_DOMAINS.map((domain) => `img[src*="${domain}"]:not([data-converted])`).join(",");

    document.querySelectorAll(selector).forEach((img) => {
      try {
        const originalSrc = img.src;
        const proxySrc = originalSrc.replace(/https?:\/\/([^/]+)\//g, "/img-proxy/$1/");

        img.src = proxySrc;
        img.setAttribute("data-converted", "true");
        console.log("Converting:", originalSrc, "", proxySrc);
      } catch (error) {
        console.error("Failed to convert image:", error);
      }
    });
  }

  if (document.readyState !== "loading") {
    convertExternalImages();
  } else {
    document.addEventListener("DOMContentLoaded", convertExternalImages);
  }

  // Check for new images every 3 seconds
  setInterval(convertExternalImages, 3000);
})();

The script maintains a list of problematic CDN domains and only processes images from those sources. When it detects a blocked image, it uses regex to rebuild the URL to route through my proxy. This focused approach avoids wasting resources on images that already load fine.

Results and Testing

After implementing both components, I tested the setup:

// Test in browser console
fetch("/img-proxy/cdn.platform.com/images/test.jpg")
  .then((response) => console.log("Status:", response.status, response.statusText))
  .catch((error) => console.error("Error:", error));

The test returned Status: 200 OK, confirming the proxy was working correctly with proper CORS headers.

With the proxy confirmed working, the dashboard transformation was impressive. Images that had been broken placeholders now loaded correctly throughout my feeds. RSS content became visually rich with thumbnails and cover images that made the dashboard much more engaging.

The JavaScript automatically converts problematic image URLs as new RSS content loads. When fresh content appears, any images from blocked domains are quickly converted to use the proxy route. The 14-day cache configuration means once an image loads through the proxy, subsequent views are instant.

Verifying Cache Performance

To verify that the caching was working as intended, I checked the Nginx cache directory:

# Count cached files
sudo find /var/cache/nginx/images -type f | wc -l

# Check cache size
sudo du -sh /var/cache/nginx/images

The cache was working as intended, with files being created and deleted as expected.


Responsible Usage

This solution requires thoughtful implementation to respect both technical constraints and content platforms. The 14-day caching configuration significantly reduces load on source CDNs—an image that might be viewed dozens of times only generates a single upstream request.

The referrer validation prevents unauthorized access. The valid_referers directive restricts proxy usage to requests originating from my dashboard domain, ensuring it can’t be exploited as an open relay by external users.

When implementing similar solutions, consider the scope and purpose of your usage to maintain ethical standards and avoid potential service violations.




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • Jekyll LiveReload vs WebSocket Secure: A Protocol Compatibility Issue
  • Surviving without VS Code Remote SSH
  • Docker API Exposure via Tailscale VPN - Linux Setup Guide
  • Docker API Exposure via Tailscale VPN - Windows Setup Guide (with WSL2 Backend)
  • The .DS_Store Strikes Back: Finder Edition