PrivateBin over Tor

This setup deploys a hardened, self-hosted PrivateBin instance as a Tor hidden service. It allows anonymous, end-to-end encrypted paste sharing without logs, trackers, or client identifiers. The configuration emphasizes strict access controls, minimal attack surface, and privacy-preserving defaults, making it suitable for sensitive text exchange entirely within the Tor network.

Note:

“You will need to append the access token to submit uploads.”

  • Replace “^YOUR_ONION.onion$” YOUR_ONION only with the onions string.

99-privatebin.conf

# No alias needed if document-root is already /var/www/privatebin

# Deny access to /index.php unless correct access token is present
# No alias needed if document-root is already /var/www/privatebin

# Block access to /index.php unless correct access token is present
#$HTTP["url"] =~ "^/index\.php" {
#    $HTTP["querystring"] !~ "access_token=BBfUxKaNYwOCBMV40LP" {
#        url.access-deny = ( "" )
#    }
#}
# Deny direct /index.php access unless token is present or referer is valid
$HTTP["url"] =~ "^/index\.php" {
    # Allow Tor access without token
    $HTTP["remoteip"] !~ "127\.0\.0\.1|::1" {
        $HTTP["host"] !~ "YOUR_ONION\.onion" {
            $HTTP["querystring"] !~ "access_token=BBfUxKaNYwOCBMV40LP" {
                url.access-deny = ( "" )
            }
        }
    }
}

99-security.conf


# ========================
# PrivateBin Hardened Security Rules
# ========================

# Disable directory listing globally
#dir-listing.activate = "disable"

# Deny access to suspicious extensions (config files, backups, temp edits)
$HTTP["url"] =~ "\.(git|svn|hg|env|bak|swp|inc|log|yaml|yml|ini)$" {
  url.access-deny = ( "" )
}

# Allow only GET and POST (no TRACE, PUT, etc.)
$HTTP["request-method"] !~ "^(GET|POST)$" {
  url.access-deny = ( "" )
}

# Block requests with no User-Agent (often bots or scanners)
$HTTP["useragent"] == "" {
  url.access-deny = ( "" )
}

# Anti-hotlinking: only allow images to be loaded from our onion
$HTTP["referer"] !~ "^($|https://YOUR_ONION\.onion)" {
  url.access-deny = ( ".jpg", ".jpeg", ".png", ".webp", ".gif" )
}

# Block known bots (example: Googlebot, Bingbot, Baiduspider)
$HTTP["useragent"] =~ "(Google|Bing|Slurp|DuckDuckBot|Baiduspider|Yandex)" {
  url.access-deny = ( "" )
}

# Security headers via mod_setenv
$HTTP["scheme"] == "https" {
  setenv.add-response-header = (
    "Referrer-Policy" => "same-origin",
    "Strict-Transport-Security" => "max-age=31536000",
    "X-Frame-Options" => "DENY",
    "X-Content-Type-Options" => "nosniff",
    "X-XSS-Protection" => "1; mode=block",
    "Permissions-Policy" => "accelerometer=(), camera=(), geolocation=(), microphone=()",
    "Server" => "PrivateBin"
  )
}

# Virtual-host validation 
$HTTP["host"] !~ "^YOUR_ONION\.onion$" {
  url.access-deny = ( "" )
}

404-privatebin.html


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>404</title>
  <style>
    html, body {
      margin: 0;
      padding: 0;
      height: 100%;
      background: black;
      overflow: hidden;
      font-family: monospace;
    }
    canvas {
      position: fixed;
      top: 0;
      left: 0;
      width: 100vw;
      height: 100vh;
      z-index: 0;
    }
    .center-404 {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      font-size: 20vw;
      font-weight: bold;
      color: transparent;
      z-index: 2;
      -webkit-text-stroke: 2px #9b59b6;
      text-shadow: 0 0 20px #9b59b6;
      pointer-events: none;
      user-select: none;
    }
    .overlay {
      position: absolute;
      top: 0;
      left: 0;
      z-index: 3;
      width: 100%;
      text-align: center;
      margin-top: 5vh;
      color: #9b59b6;
      font-size: 1.5em;
      text-shadow: 0 0 10px #9b59b6;
    }
  </style>
</head>
<body>
  <canvas id="matrix"></canvas>
  <div class="center-404">404</div>

  <script>
    const canvas = document.getElementById('matrix');
    const ctx = canvas.getContext('2d');
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;

    const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()+=<>?';
    const chars = characters.split('');
    const fontSize = 16;
    const columns = Math.floor(canvas.width / fontSize);
    const drops = Array(columns).fill(1);

    function draw() {
      ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      ctx.fillStyle = '#9b59b6';
      ctx.font = fontSize + 'px monospace';

      for (let i = 0; i < drops.length; i++) {
        const text = chars[Math.floor(Math.random() * chars.length)];
        const x = i * fontSize;
        const y = drops[i] * fontSize;

        ctx.fillText(text, x, y);

        if (y > canvas.height && Math.random() > 0.975) {
          drops[i] = 0;
        }
        drops[i]++;
      }
    }

    setInterval(draw, 33);

    window.addEventListener('resize', () => {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
    });
  </script>
</body>
</html>

Caddy file below:


# The Caddyfile is an easy way to configure your Caddy web server.
#
# Unless the file starts with a global options block, the first
# uncommented line is always the address of your site.
#
# To use your own domain name (with automatic HTTPS), first make
# sure your domain's A/AAAA DNS records are properly pointed to
# this machine's public IP, then replace ":80" below with your
# domain name.
# reverse_proxy localhost:8080
# Or serve a PHP site through php-fpm:
# php_fastcgi localhost:9000

{
    auto_https disable_redirects
    servers {
        protocols h1 h2
    }
}

YOUR_ONION.onion:443 {
	tls /etc/caddy/certs/YOUR_ONION.onion.crt /etc/caddy/certs/YOUR_ONION.onion.key
	reverse_proxy 127.0.0.1:80
}
# Refer to the Caddy docs for more information:
# https://caddyserver.com/docs/caddyfile

lighttpd.conf


server.modules = (
	"mod_indexfile",
	"mod_access",
	"mod_alias",
	"mod_redirect",
)

server.document-root        = "/var/www/privatebin"
server.upload-dirs          = ( "/var/cache/lighttpd/uploads" )
server.errorlog             = "/var/log/lighttpd/error.log"
server.pid-file             = "/run/lighttpd.pid"
server.username             = "www-data"
server.groupname            = "www-data"
server.port                 = 80

# features
#https://redmine.lighttpd.net/projects/lighttpd/wiki/Server_feature-flagsDetails
server.feature-flags       += ("server.h2proto" => "enable")
server.feature-flags       += ("server.h2c"     => "enable")
server.feature-flags       += ("server.graceful-shutdown-timeout" => 5)
#server.feature-flags       += ("server.graceful-restart-bg" => "enable")

server.error-handler-404 = "/404.html"
# server.error-handler-400 = "/404.html"
# strict parsing and normalization of URL for consistency and security
# https://redmine.lighttpd.net/projects/lighttpd/wiki/Server_http-parseoptsDetails
# (might need to explicitly set "url-path-2f-decode" = "disable"
#  if a specific application is encoding URLs inside url-path)
server.http-parseopts = (
  "header-strict"           => "enable",# default
  "host-strict"             => "enable",# default
  "host-normalize"          => "enable",# default
  "url-normalize-unreserved"=> "enable",# recommended highly
  "url-normalize-required"  => "enable",# recommended
  "url-ctrls-reject"        => "enable",# recommended
  "url-path-2f-decode"      => "enable",# recommended highly (unless breaks app)
 #"url-path-2f-reject"      => "enable",
  "url-path-dotseg-remove"  => "enable",# recommended highly (unless breaks app)
 #"url-path-dotseg-reject"  => "enable",
 #"url-query-20-plus"       => "enable",# consistency in query string
)

#index-file.names            = ( "index.php", "index.html" )
#index-file.names = ( "index.html", "index.php" )
index-file.names = ( "index.html" )

url.access-deny             = ( "~", ".inc" )
static-file.exclude-extensions = ( ".php", ".pl", ".fcgi" )

# default listening port for IPv6 falls back to the IPv4 port
include_shell "/usr/share/lighttpd/use-ipv6.pl " + server.port
include_shell "/usr/share/lighttpd/create-mime.conf.pl"
include "/etc/lighttpd/conf-enabled/*.conf"

#server.compat-module-load   = "disable"
server.modules += (
	"mod_dirlisting",
	"mod_staticfile",
        "mod_setenv",
	"mod_magnet",
)

privatebin-index.html


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Service Offline</title>
<style>
  html, body {
    margin: 0; padding: 0;
    height: 100%;
    background: black;
    overflow: hidden;
    color: #9b59b6; /* purple */
    font-family: monospace;
  }
  #matrix {
    position: fixed;
    top: 0; left: 0;
    width: 100vw;
    height: 100vh;
    z-index: 0;
  }
  .message-container {
    position: relative;
    z-index: 1;
    color: #9b59b6;
    text-align: center;
    top: 40%;
    font-size: 2em;
    font-weight: bold;
    text-shadow: 0 0 8px #9b59b6;
  }
</style>
</head>
<body>
<canvas id="matrix"></canvas>

<div class="message-container">
  <h2>Temporarily Unavailable</h2>
  <p>This service is currently down for maintenance.</p>
</div>

<script>
  const canvas = document.getElementById('matrix');
  const ctx = canvas.getContext('2d');

  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;

  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()-+=<>?';
  const charsArray = characters.split('');

  const fontSize = 18;
  const columns = Math.floor(canvas.width / fontSize);

  const drops = [];
  for (let x = 0; x < columns; x++) drops[x] = Math.random() * canvas.height;

  function draw() {
    ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    ctx.fillStyle = '#9b59b6'; // purple text
    ctx.font = fontSize + 'px monospace';

    for (let i = 0; i < drops.length; i++) {
      const text = charsArray[Math.floor(Math.random() * charsArray.length)];
      ctx.fillText(text, i * fontSize, drops[i] * fontSize);

      if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) {
        drops[i] = 0;
      }
      drops[i]++;
    }
  }

  setInterval(draw, 33);

  window.addEventListener('resize', () => {
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
  });
</script>
</body>
</html>

Tor configuration file “/etc/tor/torrc file for privatebin”


## by removing the "#" symbol.
##
## See 'man tor', or https://www.torproject.org/docs/tor-manual.html,
## for more options you can use in this file.
##
## Tor will look for this file in various places based on your platform:
## https://www.torproject.org/docs/faq#torrc
HiddenServiceDir /var/lib/tor/ssh
HiddenServicePort 6666 127.0.0.1:6666
Log notice stdout
Log info file /var/log/tor/info_new.log
#ClientUseIPv4 0
ClientPreferIPv6ORPort 1

log debug file /var/log/tor/debug.log

## PrivateBin Hidden Service
HiddenServiceDir /var/lib/tor/privatebin_onion/
HiddenServicePort 443 127.0.0.1:443

DirReqStatistics 0


install_privatebin.sh

#!/bin/bash

set -e

echo "[*] Installing dependencies..."
apt update
apt install -y lighttpd php php-cgi unzip curl tor

echo "[*] Enabling CGI for PHP..."
lighty-enable-mod fastcgi-php
systemctl restart lighttpd

echo "[*] Downloading PrivateBin..."
mkdir -p /var/www/privatebin
cd /var/www/privatebin
curl -L https://github.com/PrivateBin/PrivateBin/archive/refs/tags/2.0.0.zip -o privatebin.zip
unzip -o privatebin.zip
mv PrivateBin-*/* .
rm -rf PrivateBin-master privatebin.zip

echo "[*] Setting permissions..."
chown -R www-data:www-data /var/www/privatebin
chmod -R 755 /var/www/privatebin

echo "[*] Configuring PrivateBin..."
cp cfg/conf.sample.php cfg/conf.php

sed -i "s/'cipher' => 'aes'/'cipher' => 'chacha20'/" cfg/conf.php
sed -i "s/'discussion' => true/'discussion' => false/" cfg/conf.php
sed -i "s/'fileupload' => true/'fileupload' => false/" cfg/conf.php
sed -i "s/'burnafterreading' => false/'burnafterreading' => true/" cfg/conf.php

echo "[*] Configuring lighttpd..."
cat > /etc/lighttpd/conf-available/99-privatebin.conf <<EOF
server.modules += ("mod_alias", "mod_cgi")

alias.url += (
    "/privatebin/" => "/var/www/privatebin/"
)

\$HTTP["url"] =~ "^/privatebin/" {
    cgi.assign = ( ".php" => "/usr/bin/php-cgi" )
}
EOF

ln -s /etc/lighttpd/conf-available/99-privatebin.conf /etc/lighttpd/conf-enabled/
systemctl reload lighttpd

echo "[*] Configuring Tor Hidden Service..."
mkdir -p /var/lib/tor/privatebin_onion/
chown -R debian-tor:debian-tor /var/lib/tor/privatebin_onion/
chmod 700 /var/lib/tor/privatebin_onion/

cat >> /etc/tor/torrc <<EOF

## PrivateBin Hidden Service
HiddenServiceDir /var/lib/tor/privatebin_onion/
HiddenServicePort 80 127.0.0.1:80
EOF

echo "[*] Restarting Tor..."
systemctl restart tor

sleep 3
echo "[+] PrivateBin .onion address:"
cat /var/lib/tor/privatebin_onion/hostname