[Из песочницы] После прочтения сжечь. Делаем одноразовые ссылки на голом Nginx

habr.png

Для начала нужно уточнить, что я настоятельно не рекомендую использовать это решение в боевых условиях. Лучше вовсе так не делать никогда. Всё, что вы делаете, вы делаете на свой страх и риск. Причины, которые заставляют дать такой совет, будут приведены в содержании статьи. Если это предупреждение вас не отпугнуло, то добро пожаловать под кат.

Под «голым Nginx» понимается пакет для Ubuntu 16.04 из mainline ветки официального репозитория, который уже собран с ключом --with-http_dav_module.

Предполагается, что у вас уже есть настроенный nginx в такой же «комплектации», следовательно, ниже будет описываться лишь настройка двух location, которые вы добавите в свою секцию server конфига nginx

В моём случае все временные файлы будут храниться в папке /var/www/upload, ссылки на файл будут иметь вид example.com/upload/random_folder_name/filename, где в качестве random_folder_name будет рандомная строка из нужного нам количества байт, потому создаём location вида

location ~ ^/upload/([\w]+)/([^/]*)?$ {
    root /var/www;

    if ($request_method !~ ^(GET|PUT|DELETE)$) {
        return 444;
    }

    client_body_buffer_size 2M;
    client_max_body_size 1G;

    dav_methods PUT DELETE;
    dav_access group:rw all:r;
    create_full_put_path on;
}


Проверяем, что загрузка, получение и удаление файлов и папок работает командами в консоли

curl -X PUT -T test.txt https://example.com/upload/random_folder_name/
curl https://example.com/upload/random_folder_name/test.txt 
curl -X DELETE https://example.com/upload/random_folder_name/


Если тесты прошли успешно, то начинаем делать магию, а именно:

  • Если единожды попросить у nginx файл, то он его закеширует и будет снова и снова его отдавать, даже если файл удалить с диска. Это не укладывается в нашу концепцию одноразовых ссылок, потому необходимо, следуя инструкции привести директиву open_file_cache к значению off
    open_file_cache off;
  • Для того, чтобы оградить свой сервер от неконтролируемого потока загружаемых файлов, добавим проверку токена, который мы будем передавать заголовком Token. В конфиге это будет выглядеть следующим образом
    set $token "cb110ef4c4165e495001e297feae7092";
    ...
    if ($http_token != $token) {
        return 444;
    }
    

    Сам токен можно сгенерировать в консоли командой вида
    cat /dev/urandom | head -c16 | xxd -ps | tr -d "\n"; echo ""
  • Для того, чтобы все файлы отдавались как аттачи, в том числе и html, необходимо их отдавать с заголовками Content-Type: application/octet-stream и Content-Disposition: attachment. А также, чтобы «умные» браузеры, например Internet Explorer, не могли переопределить content type на основе содержимого файла,
    нужен заголовок X-Content-Type-Options: nosniff. В конфиге это будет выглядеть следующим образом
    types { }
    default_type application/octet-stream;
    add_header Content-Disposition "attachment";
    add_header X-Content-Type-Options "nosniff";
    
  • Для удаления файла после отдачи мы заведём именованный location
    location @delete {
        if ($request_uri ~ ^/upload/([\w]+)/([^/]*)$) {
            set $path $1;
            set $token "cb110ef4c4165e495001e297feae7092";
        }
        proxy_method DELETE;
        proxy_set_header Token $token;
        proxy_pass https://example.com/upload/$path/;
    }
    

    И это первое место, из-за которого эта схема просто не имеет права на существование. Из-за того, что здесь используется if, а он в свою очередь is evil. Так делать нельзя, хотя в данном случае оно работает.

    Итак, если вы готовы продолжать, то стоит описать, что происходит в данном location — мы принимаем запрос, проверяем что путь соответствует паттерну /upload/random_folder_name/filename, из него получаем random_folder_name, и отправляем запрос на удаление этой папки в себя же

  • Единственный нераскрытый вопрос — как сделать так, чтобы файл, после того как был отдан, был удалён. На него ответит очередная магия, вторая по счёту, из-за которой эта реализация одноразовых ссылок не должна существовать:
    if ($request_method = GET) {
        set $http_token $token;
        post_action @delete;
    }
    

    Выглядит вполне прилично, но не нужно использовать то, что непонятно как работает и не задокументировано


Теперь всё это объединяем в один конфиг, который, в моём случае, выглядит вот так:

location ~ ^/upload/([\w]+)/([^/]*)?$ {

    root /var/www;
    open_file_cache off;

    if ($request_method !~ ^(GET|PUT|DELETE)$) {
        return 444;
    }

    set $token "cb110ef4c4165e495001e297feae7092";

    types { }
    default_type application/octet-stream;
    add_header Content-Disposition "attachment";
    add_header X-Content-Type-Options "nosniff";

    if ($request_method = GET) {
        set $http_token $token;
        post_action @delete;
    }

    if ($http_token != $token) {
        return 444;
    }

    client_body_buffer_size 2M;
    client_max_body_size 1G;
    dav_methods PUT DELETE;
    dav_access group:rw all:r;
    create_full_put_path on;
}

location @delete {
    if ($request_uri ~ ^/upload/([\w]+)/([^/]*)$) {
        set $path $1;
        set $token "cb110ef4c4165e495001e297feae7092";
    }
    proxy_method DELETE;
    proxy_set_header Token $token;
    proxy_pass https://example.com/upload/$path/;
}


Всё отлично, но пользоваться этим крайне неудобно, ибо придётся вбивать в консоли длинные команды на загрузку файла. Чтобы этого избежать и максимально упростить загрузку файлов и папок, я набросал в .zshrc (предполагаю, что будет работать и в .bashrc)

функцию
upload() {
    if [ $# -eq 0 ]; then
        echo "Usage:
upload [file|folder] [option]
cat file | upload [name] [option]

Options:
gpg     - Encrypt file. The folder is pre-packed to tar
gzip    - Pack to gzip archive. The folder is pre-packed to tar
"
        return 1
    fi

    uri="https://example.com/upload"
    token="cb110ef4c4165e495001e297feae7092"
    random=$(cat /dev/urandom | head -c8 | xxd -ps | tr -d "\n")

    if tty -s; then
        name=$(basename "$1")
        if [ "$2" = "gpg" ]; then
            passphrase=$(cat /dev/urandom | tr -dc "[:graph:]" | head -c16)
            echo "$passphrase"
            if [ "$1" = "-" ]; then
                name=$(basename $(pwd))
                tar cf - `ls -1 $(pwd)` | gpg --passphrase-file <(echo -n "$passphrase") --batch -ac -o- | curl -I --progress-bar -H "Token: $token" -T "-" "$uri/$random/$name.tar.gpg" | grep "Location: " | cut -d " " -f2
            elif [ -d "$1" ]; then
                tar cf - `ls -1 "$1"` | gpg --passphrase-file <(echo -n "$passphrase") --batch -ac -o- | curl -I --progress-bar -H "Token: $token" -T "-" "$uri/$random/$name.tar.gpg" | grep "Location: " | cut -d " " -f2
            elif [ -f "$1" ]; then
                gpg --passphrase-file <(echo -n "$passphrase") --batch -ac -o- "$1" | curl -I --progress-bar -H "Token: $token" -T "-" "$uri/$random/$name.gpg" | grep "Location: " | cut -d " " -f2
            fi
        elif [ "$2" = "gzip" ]; then
            if [ "$1" = "-" ]; then
                name=$(basename $(pwd))
                tar czf - `ls -1 $(pwd)` | curl -I --progress-bar -H "Token: $token" -T "-" "$uri/$random/$name.tar.gz" | grep "Location: " | cut -d " " -f2
            elif [ -d "$1" ]; then
                tar czf - `ls -1 "$1"` | curl -I --progress-bar -H "Token: $token" -T "-" "$uri/$random/$name.tar.gz" | grep "Location: " | cut -d " " -f2
            elif [ -f "$1" ]; then
                gzip -c "$1" | curl -I --progress-bar -H "Token: $token" -T "-" "$uri/$random/$name.gz" | grep "Location: " | cut -d " " -f2
            fi
        else
            if [ "$1" = "-" ]; then
                name=$(basename $(pwd))
                tar cf - `ls -1 $(pwd)` | curl -I --progress-bar -H "Token: $token" -T "-" "$uri/$random/$name.tar" | grep "Location: " | cut -d " " -f2
            elif [ -d "$1" ]; then
                tar cf - `ls -1 "$1"` | curl -I --progress-bar -H "Token: $token" -T "-" "$uri/$random/$name.tar" | grep "Location: " | cut -d " " -f2
            elif [ -f "$1" ]; then
                curl -I --progress-bar -H "Token: $token" -T "$1" "$uri/$random/$name" | grep "Location: " | cut -d " " -f2
            fi
        fi
    else
        if [ "$2" = "gpg" ]; then
            passphrase=$(cat /dev/urandom | tr -dc "[:graph:]" | head -c16)
            echo "$passphrase"
            gpg --passphrase-file <(echo -n "$passphrase") --batch -ac -o- | curl -I --progress-bar -H "Token: $token" -T "-" "$uri/$random/$1.gpg" | grep "Location: " | cut -d " " -f2
        elif [ "$2" = "gzip" ]; then
            gzip | curl -I --progress-bar -H "Token: $token" -T "-" "$uri/$random/$1.gz" | grep "Location: " | cut -d " " -f2
        else
            curl -I --progress-bar -H "Token: $token" -T "-" "$uri/$random/$1" | grep "Location: " | cut -d " " -f2
        fi
    fi
}


Минусы реализации, помимо того, что так делать не стоит:

  • Нет докачки. Если оборвалось соединение, то nginx исполнит директиву post_action и удалит файл
  • Если передавать ссылку, например, через Telegram, то он попытается получить превью этого файла, и, следовательно, файл тут же удалится, и адресат увидит вместо файла 404. Для того, чтобы этого избежать, можно добавить условие вида
    if ($http_user_agent ~* (bot)) {
        return 444;
    }
    

    в первый location. Это спасёт как минимум от Telegram, но не факт, что спасёт от кого-то другого


Надеюсь эта статья будет кому-то полезна, он почерпнёт из неё новые знания и не будет делать так никогда

© Habrahabr.ru