← Back

Gitea Behind nginx: Restricting CORS, Blocking Hotlinking, and Tuning the Cache

Gitea Behind nginx: Restricting CORS, Blocking Hotlinking, and Tuning the Cache
Contents

Introduction

It started when my server monitor kept showing abnormal load and bandwidth on the machine I use only for hosting websites.

Abnormal load

I shut everything down and checked services one at a time. With only the Gitea Docker container running there was no abnormal load, and with every service except the Gitea reverse proxy running there was none either. But as soon as I enabled the nginx config for the Gitea reverse proxy, the monitor immediately showed bandwidth being eaten and CPU usage regularly maxing out. I looked at the nginx access log:

Access log

As the picture shows, lots of bots (crawlers) were crawling my Gitea. Looking back, I had pulled some static assets (Font Awesome and the like) into Gitea, set those repositories as mirrors, and turned on scheduled syncing. The goal was to serve my main site’s assets from my own Gitea to speed things up. But exactly that, the repeated pulls and the mirror setup, is what led these legitimate crawlers to my Gitea. Most of them belonged to big companies such as OpenAI, Amazon and Claude. Whatever their purpose, I didn’t want my Gitea’s bandwidth and resources tied up by endless pointless GETs. So I tinkered to solve this, which led to more tinkering in other areas, such as CORS restrictions and hotlink protection. Sure enough, life is all about tinkering (kidding). First, the crawlers. These legitimate crawlers respect the gentlemen’s agreement, robots.txt, so we can put a robots.txt in the site’s root directory.

User-agent: *
Disallow: /

The User-agent field sets which bots the rule matches; * matches all of them. The Disallow field sets the paths that may not be accessed; / means all of them. However, the main location in my Gitea nginx config proxies to http://localhost:3000, so /robots.txt can’t be served. The following goes before all other location blocks:

location = /robots.txt {
    root /path/to/your/domain;
    try_files $uri =404;
}

OK, that’s the end of the introduction. Two or three days after doing the above, the legitimate crawlers really did stop crawling my site, and bandwidth and load went back to normal. By rights I should have stopped exploring there, right? But life is all about tinkering! If I didn’t keep going, something would always feel imperfect and incomplete. On to the main part: to let only my own websites request assets from my Gitea, I need CORS restrictions and hotlink protection.

Main nginx config

First edit the main nginx config. To keep things separate, I created a dedicated gitea.conf under /.../nginx/customize/

    # Cache path; fill in your own path.
    proxy_cache_path /path/to/your/domain/cache
        levels=1:2
        keys_zone=GITEA_CACHE:256m
        inactive=7d
        max_size=10g
        use_temp_path=off;

    # Dynamic cache-bypass map
    map $request_method $skip_cache {
        default     0;
        POST        1;
        PUT         1;
        DELETE      1;
        PATCH       1;
    }

    # CORS config
    map $http_origin $gitea_allow_origin {
        default "";
        "~^https?://(.*\.)?scarle7\.tech(:[0-9]+)?$" $http_origin;
        "~^https?://(.*\.)?flandre\.vip(:[0-9]+)?$" $http_origin;
        "~^https?://git\.scarle7\.tech(:[0-9]+)?$" $http_origin;
    }

The GITEA_CACHE after keys_zone is a name we define, and so is gitea_allow_origin; the config below refers to both. Since the content above actually belongs in the http block, include that file in the http block of the main config nginx.conf.

...... #other content
http {
    include /.../nginx/customize/gitea.conf
    # leave the rest of the existing config unchanged
}

CORS restrictions

We already defined the gitea_allow_origin variable above, containing the allowed domains, so we can use it directly for control.

Note: the “≠” in the code highlighting below is actually “!=”. The code is rendered in JetBrains’ default modern font, so it looks the same as in JetBrains. This is called a “ligature”: it only makes the code look nicer and doesn’t affect copying. You’ll find you can’t copy the “≠” on its own, because it is really two characters. Likewise, JetBrains renders “<=” as “≤”, and there are many other ligatures to discover.

        if ($gitea_allow_origin != "") {
            add_header 'Access-Control-Allow-Origin' $gitea_allow_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
            add_header 'Access-Control-Allow-Credentials' 'true';
        }

Preflight request handling

        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' $gitea_allow_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
            add_header 'Access-Control-Allow-Credentials' 'true';
            add_header 'Access-Control-Max-Age' 1728000;
            add_header 'Content-Type' 'text/plain charset=UTF-8';
            add_header 'Content-Length' 0;
            return 204;
        }

All of the above lives inside the main location block. The complete main location should look like this:

# Main request handling
    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        # CORS control (uses the dedicated variable)
        if ($gitea_allow_origin != "") {
            add_header 'Access-Control-Allow-Origin' $gitea_allow_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
            add_header 'Access-Control-Allow-Credentials' 'true';
        }

        # Preflight request handling
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' $gitea_allow_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
            add_header 'Access-Control-Allow-Credentials' 'true';
            add_header 'Access-Control-Max-Age' 1728000;
            add_header 'Content-Type' 'text/plain charset=UTF-8';
            add_header 'Content-Length' 0;
            return 204;
        }
    }

This is simple: nginx’s built-in valid_referers and invalid_referer do the job. Note that even though the main location already proxies, you have to set the proxy again here.

    location ~* \.(css|js|png|jpe?g|gif|ico|svg|woff2|ttf|woff)$ {
    valid_referers none blocked server_names
            scarle7.tech www.scarle7.tech *.scarle7.tech
            flandre.vip www.flandre.vip *.flandre.vip;
        if ($invalid_referer) {
            return 403;
        }
    proxy_pass http://localhost:3000;
}

Warning

With the settings above, requests from allowed domains to git.scarle7.tech get Access-Control-Allow-Origin and work fine, but responses for the listed static assets don’t include Access-Control-Allow-Origin, so the browser blocks them with CORB.

The reason is that this location overrides the CORS handling for static assets: assets not listed are still handled by the main location, but the listed ones are no longer handled by it. The fix is to add the CORS handling to the hotlink-protection location block as well. The complete hotlink-protection config is:

location ~* \.(css|js|png|jpe?g|gif|ico|svg|woff2|ttf|woff)$ {
    valid_referers none blocked server_names
            scarle7.tech www.scarle7.tech *.scarle7.tech
            flandre.vip www.flandre.vip *.flandre.vip;
        if ($invalid_referer) {
            return 403;
        }

        if ($gitea_allow_origin != "") {
            add_header 'Access-Control-Allow-Origin' $gitea_allow_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
            add_header 'Access-Control-Allow-Credentials' 'true';
        }

        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' $gitea_allow_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
            add_header 'Access-Control-Allow-Credentials' 'true';
            add_header 'Access-Control-Max-Age' 1728000;
            add_header 'Content-Type' 'text/plain charset=UTF-8';
            add_header 'Content-Length' 0;
            return 204;
        }
    }

Cache control

The GITEA_CACHE path defined above doesn’t exist yet, so create it and set permissions first.

sudo mkdir -p /path/to/your/domain/cache
sudo chown -R www:www /path/to/your/domain/cache
sudo chmod -R 755 /path/to/your/domain/cache

Add the following to each of the two location blocks above

# main
        proxy_cache GITEA_CACHE;
        proxy_cache_valid 200 302 12h;
        proxy_cache_bypass $skip_cache;
        add_header X-Cache-Status $upstream_cache_status;
# place it between the proxy part and the CORS part
# ....proxy part   cache part    CORS part....

# hotlink protection
        proxy_cache GITEA_CACHE;
        proxy_cache_valid 200 302 30d;
        expires 365d;
        add_header Cache-Control "public, immutable";
# place it before proxy_pass
# ....    cache part   proxy_pass

For gitea, we shouldn’t cache api and admin; those pages must always be live. So a final location handles what must not be cached

    location ~ ^/(api|admin) {
        proxy_pass http://localhost:3000;
        proxy_no_cache 1;
        proxy_cache_bypass 1;
        add_header Cache-Control "no-store";
    }

That’s all the configuration; just reload. The full reference config is at the end. Once done, check that Gitea still works normally, then move on to testing.

1. CORS

Allowed domain

curl -I -H "Origin: https://scarle7.tech" https://git.scarle7.tech
# returned: Access-Control-Allow-Origin: https://scarle7.tech √ passed

Disallowed domain

For example Baidu

curl -I -H "Origin: https://baidu.com" https://git.scarle7.tech
# no Access-Control-Allow-Origin header √ passed

Allowed referer

curl -I -e "https://scarle7.tech" https://git.scarle7.tech/static/image.png
# returns 200 √ passed

Disallowed referer

curl -I -e "https://steal.com" https://git.scarle7.tech/static/image.png
# returns 403 Forbidden √ passed

3. Caching works

# first request (should MISS)
curl -I https://git.scarle7.tech/test/test.css
# actual response header: X-Cache-Status: MISS

# second request (should HIT)
curl -I https://git.scarle7.tech/test/test.css
# actual response header: X-Cache-Status: HIT
# √ passed

Full config

The full config should add security headers in the server block

server
{
    ......
    ......
    # other config, including listen ports, ssl and other basics

    add_header Strict-Transport-Security "max-age=31536000";
    add_header Alt-Svc 'quic=":443"; h3=":443"; h3-29=":443"; h3-27=":443";h3-25=":443"; h3-T050=":443"; h3-Q050=":443";h3-Q049=":443";h3-Q048=":443"; h3-Q046=":443"; h3-Q043=":443"';
    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";
    add_header Referrer-Policy "strict-origin-when-cross-origin";

    # robots.txt
    location = /robots.txt {
    root /www/wwwroot/git.scarle7.tech;
    try_files $uri =404;
        }

    # Main request handling
    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        # Cache config
        proxy_cache GITEA_CACHE;
        proxy_cache_valid 200 302 12h;
        proxy_cache_bypass $skip_cache;
        add_header X-Cache-Status $upstream_cache_status;

        # CORS config
        if ($gitea_allow_origin != "") {
            add_header 'Access-Control-Allow-Origin' $gitea_allow_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
            add_header 'Access-Control-Allow-Credentials' 'true';
        }

        # Preflight request handling
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' $gitea_allow_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
            add_header 'Access-Control-Allow-Credentials' 'true';
            add_header 'Access-Control-Max-Age' 1728000;
            add_header 'Content-Type' 'text/plain charset=UTF-8';
            add_header 'Content-Length' 0;
            return 204;
        }
    }

    # Static asset hotlink protection
    location ~* \.(css|js|png|jpe?g|gif|ico|svg|woff2|ttf|woff)$ {
    valid_referers none blocked server_names
            scarle7.tech www.scarle7.tech *.scarle7.tech
            flandre.vip www.flandre.vip *.flandre.vip;
        if ($invalid_referer) {
            return 403;
        }

        if ($gitea_allow_origin != "") {
            add_header 'Access-Control-Allow-Origin' $gitea_allow_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
            add_header 'Access-Control-Allow-Credentials' 'true';
        }

        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' $gitea_allow_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
            add_header 'Access-Control-Allow-Credentials' 'true';
            add_header 'Access-Control-Max-Age' 1728000;
            add_header 'Content-Type' 'text/plain charset=UTF-8';
            add_header 'Content-Length' 0;
            return 204;
        }

        # Stronger caching
        proxy_cache GITEA_CACHE;
        proxy_cache_valid 200 302 30d;
        expires 365d;
        add_header Cache-Control "public, immutable";
        proxy_pass http://localhost:3000;
    }

    # Content that is never cached
    location ~ ^/(api|admin) {
        proxy_pass http://localhost:3000;
        proxy_no_cache 1;
        proxy_cache_bypass 1;
        add_header Cache-Control "no-store";
    }

    if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
        return 403;
    }

    access_log  /path/to/logs/access.log;
    error_log  /path/to/logs/error.log;
}

END