Skip to content

Proxying External Images over HTTPS (Camo) ​

Install Camo (optional) so images embedded from other websites load through your server over HTTPS, avoiding "mixed content" warnings and hiding viewers' IPs.

⏱ ~45 min · 👤 Operators · 🔑 SSH + docker access on the server, permission to edit dmoj/environment/ and nginx.conf

Do you need this?

When a statement, blog post, or comment embeds an image from another website (especially an http:// image), browsers may block it or show a "mixed content" warning because your site is served over HTTPS. The image host also sees every viewer's IP address.

Camo is an image proxy: LCOJ rewrites external image links so browsers load them through your own Camo server, over HTTPS.

  • LCOJ works fine without Camo. External images still show up when their source uses HTTPS.
  • A simpler approach: encourage people to upload images to LCOJ with the editor's image button instead of linking external ones.

Status in LCOJ ​

ComponentStatus
DMOJ_CAMO_URL, DMOJ_CAMO_KEY, DMOJ_CAMO_EXCLUDE, DMOJ_CAMO_HTTPSCommented out in dmoj/config/local_settings.py (disabled)
Camo service in docker-compose.ymlNot present
location /camo/ in dmoj/nginx/conf.d/nginx.confNot present

How it works ​

  • While rendering Markdown, LCOJ rewrites the src and data-src attributes of <img> tags and the data attribute of <object> tags.
  • The new URL looks like <DMOJ_CAMO_URL>/<HMAC-SHA1 signature>/<hex-encoded original URL>. Camo verifies the signature with CAMO_KEY, so outsiders can't use your Camo to proxy arbitrary links.
  • Not rewritten: relative paths (for example /martor/a.png), URLs starting with DMOJ_CAMO_URL, and URLs starting with any prefix in DMOJ_CAMO_EXCLUDE.
  • Every LCOJ Markdown style (statements, blogs, comments, profiles, and so on) has use_camo enabled, so once configured it applies everywhere.

Before you start ​

Installation (optional) ​

Camo is not part of docker-compose.yml. You run it as a separate container on the nginx network so nginx can forward /camo/ to it.

Step 1: Generate a secret key ​

sh
openssl rand -hex 32

The same key is used by Camo (CAMO_KEY) and by LCOJ (DMOJ_CAMO_KEY).

Step 2: Store the key in env files ​

Create dmoj/environment/camo.env (this directory is already gitignored):

env
CAMO_KEY=<the key you generated>

Add to dmoj/environment/site.env:

env
DMOJ_CAMO_KEY=<the same key>

WARNING

Use a separate env file for Camo. Don't let the Camo container read site.env, since it contains the site's secrets.

Step 3: Add the service to Compose ​

The atmos/camo repo ships a Dockerfile (based on node:8.4, which is quite old). Compose can build straight from Git. Create or extend dmoj/docker-compose.override.yml:

yaml
services:
  camo:
    build: https://github.com/atmos/camo.git
    restart: unless-stopped
    env_file: [environment/camo.env]
    environment:
      CAMO_LENGTH_LIMIT: "5242880"   # 5 MB limit (the default)
    networks: [nginx]

Step 4: Add an nginx location ​

Inside the server block of dmoj/nginx/conf.d/nginx.conf:

nginx
location /camo/ {
    proxy_pass http://camo:8081/;
}

The trailing / on proxy_pass strips the /camo prefix before the request reaches Camo.

Step 5: Configure LCOJ ​

Add to dmoj/config/local_settings.py, then copy it to dmoj/repo/dmoj/local_settings.py (the file the site actually reads; see Environment and configuration):

python
DMOJ_CAMO_URL = 'https://lcoj.example.com/camo'
DMOJ_CAMO_KEY = os.environ.get('DMOJ_CAMO_KEY')
# URL prefixes that should not be proxied. MUST be a tuple (not a list).
DMOJ_CAMO_EXCLUDE = ('https://lcoj.example.com/', 'http://lcoj.example.com/')
# Treat //host/... URLs as https://
DMOJ_CAMO_HTTPS = True
SettingDefault (dmoj/settings.py)Notes
DMOJ_CAMO_URLNonePublic URL of Camo, no trailing / needed
DMOJ_CAMO_KEYNoneMust match CAMO_KEY. If the URL or key is missing, Camo is off
DMOJ_CAMO_EXCLUDE()A tuple of URL prefixes (including https://), not bare domain names
DMOJ_CAMO_HTTPSFalseUse https: for URLs starting with //

Step 6: Start it ​

sh
cd dmoj
docker compose up -d --build camo
docker compose up -d site nginx      # recreate site so it reads the new site.env
docker compose restart nginx         # load location /camo/ if nginx wasn't recreated

Verify ​

  1. Generate a Camo URL with the management command:

    sh
    ./scripts/manage.py camo http://example.com/image.png

    It prints a URL like https://lcoj.example.com/camo/<hmac>/<hex>. If it says Camo not available, DMOJ_CAMO_URL or DMOJ_CAMO_KEY has no value.

  2. Open that URL in a browser; the image should load.

  3. Post a test comment with an external image and view the page source: src should start with https://lcoj.example.com/camo/.

Old pages still show the original links?

Statement HTML and some other pages are cached for up to 1 day. Save the problem again or wait for the cache to expire.

Camo environment variables ​

From the atmos/camo README:

VariableDefaultMeaning
PORT8081Port Camo listens on
CAMO_KEYa publicly known default keyKey used to verify HMAC signatures. Always set it; never rely on the default
CAMO_LENGTH_LIMIT5242880Maximum Content-Length (bytes) that will be proxied
CAMO_MAX_REDIRECTS4Maximum number of redirects to follow
CAMO_SOCKET_TIMEOUT10Seconds to wait before giving up
CAMO_LOGGING_ENABLEDdisabledSet to debug for verbose logs
CAMO_HEADER_VIACamo Asset Proxy <version>Value of the Via and User-Agent headers sent to image hosts
CAMO_TIMING_ALLOW_ORIGIN(not set)Value of the Timing-Allow-Origin header returned to browsers
CAMO_HOSTNAMEunknownValue of the Camo-Host header
CAMO_KEEP_ALIVEfalseEnable keep-alive

Camo filters content types against its built-in list of image MIME types (mime-types.json); there is no variable to change that list.

Caching and load limits ​

Camo has no cache of its own. CAMO_HEADER_VIA and CAMO_TIMING_ALLOW_ORIGIN are just headers and have nothing to do with caching. If you need them:

  • Rate limiting with nginx. Put limit_req_zone at the top of nginx.conf (outside the server block), then use it in the location:

    nginx
    limit_req_zone $binary_remote_addr zone=camo:10m rate=10r/s;
    
    server {
        # ...
        location /camo/ {
            limit_req zone=camo burst=20;
            proxy_pass http://camo:8081/;
        }
    }
  • Caching with nginx proxy_cache (in the container or in the host reverse proxy) for the /camo/ path.

Troubleshooting ​

SymptomCauseFix
Image links aren't rewrittenDMOJ_CAMO_URL/DMOJ_CAMO_KEY missing, or the page is cachedRun ./scripts/manage.py camo <url> to check; save the problem again
TypeError mentioning startswithDMOJ_CAMO_EXCLUDE is a listMake it a tuple
/camo/... returns an LCOJ 404No location /camo/ in nginxAdd the location, then docker compose restart nginx
/camo/... returns 502The Camo container isn't running or isn't on the nginx networkdocker compose ps camo, docker compose logs -f camo
Camo returns 404 (set CAMO_LOGGING_ENABLED=debug to see a checksum mismatch log)CAMO_KEY differs from DMOJ_CAMO_KEYUse the same key and recreate the containers
Some images still don't loadThe host blocks proxies, the image is over 5 MB, or it isn't an imageUpload the image to LCOJ instead of linking it

Notes ​

  • Camo uses your bandwidth, because every external image passes through your server.
  • Camo only proxies images; don't use it for video.

Next steps ​

Need help?

Open an issue at github.com/luyencode/lcoj-docker/issues, find more at behitek.com, or contact us via luyencode.net/about/#lien-he.