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.