Skip to content

Latest commit

 

History

History
515 lines (416 loc) · 17.6 KB

File metadata and controls

515 lines (416 loc) · 17.6 KB

URL Shortener

A production-shaped secure short-link service that mints opaque slugs, defends the redirect endpoint against open-redirect and SSRF-style abuse, and throttles link creation with per-identity rate limiting.


Overview

The application turns a long, user-supplied destination URL into a short slug (https://sho.rt/aB3xK9) and redirects visitors from the slug back to the destination. It is a deliberately narrow attack surface, but a naive implementation is dangerous: an unvalidated redirector is a textbook open redirect (used to lend legitimacy to phishing), a leaky one enables SSRF if the app later fetches the target for previews, and an unthrottled POST /shorten becomes free infrastructure for spam and malware distribution.

Threat model — what we defend against:

  • Open redirect / phishing chaining — attacker submits a target that bounces users to evil.example. Mitigated by strict scheme allow-listing (http/https only), host validation, and an interstitial warning for off-site jumps.
  • SSRF via link previews — a stored target of http://169.254.169.254/ or http://localhost:6379/ could hit cloud metadata or internal services. Mitigated by rejecting private, loopback, link-local, and reserved (RFC-1918) IP ranges at creation time and re-checking at fetch time (DNS-rebinding aware).
  • Abuse / spam floods — mass link creation. Mitigated by fixed-window rate limiting keyed on authenticated user or client IP, plus a blocklist of known-bad hosts.
  • Enumeration — sequential IDs leak volume and allow scraping. Mitigated by random, non-sequential slugs from a URL-safe alphabet.
  • Standard web risks — SQLi (PDO prepared statements), CSRF (synchronizer tokens), XSS (context-aware output escaping), session fixation (regeneration on login).

Architecture

flowchart LR
    U[Client] -->|POST /shorten + CSRF| R[Front Controller\npublic/index.php]
    U -->|GET /{slug}| R
    R --> M{Router}
    M -->|/shorten| SC[ShortenController]
    M -->|/{slug}| RC[RedirectController]
    SC --> RL[RateLimiter]
    SC --> UV[UrlValidator\nscheme/host/IP checks]
    SC --> LR[LinkRepository\nPDO]
    RC --> LR
    RC --> IN[Interstitial\nwarn on off-site]
    LR --> DB[(MySQL)]
    RL --> DB
Loading

Data flow for creation: request → CSRF check → rate-limit check → UrlValidator normalises and rejects unsafe targets → LinkRepository inserts a prepared row with a freshly generated unique slug → response renders the short URL (escaped). Data flow for redirect: GET /{slug} → repository lookup by slug → if the destination host is off-site, render an interstitial confirmation, else issue a 302 with a hardened Location header.


Folder Structure

url-shortener/
├── composer.json
├── docker-compose.yml
├── Dockerfile
├── docker/
│   ├── nginx.conf
│   └── php-fpm.ini
├── public/
│   └── index.php            # front controller (only web-exposed file)
├── src/
│   ├── Http/
│   │   ├── Router.php
│   │   ├── ShortenController.php
│   │   └── RedirectController.php
│   ├── Domain/
│   │   ├── UrlValidator.php
│   │   └── SlugGenerator.php
│   ├── Data/
│   │   ├── Database.php
│   │   └── LinkRepository.php
│   ├── Security/
│   │   ├── Csrf.php
│   │   └── RateLimiter.php
│   └── Support/
│       └── Session.php
├── templates/
│   ├── home.php
│   └── interstitial.php
├── tests/
│   └── UrlValidatorTest.php
└── migrations/
    └── 001_init.sql

Database Schema

CREATE TABLE links (
    id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    slug         VARCHAR(16)     NOT NULL,
    destination  VARCHAR(2048)   NOT NULL,
    created_by   BIGINT UNSIGNED NULL,
    created_ip   VARBINARY(16)   NULL,          -- packed inet_pton()
    click_count  BIGINT UNSIGNED NOT NULL DEFAULT 0,
    is_disabled  TINYINT(1)      NOT NULL DEFAULT 0,
    created_at   TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uq_links_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE rate_limits (
    id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    identity      VARCHAR(64)     NOT NULL,      -- 'user:42' or 'ip:<hash>'
    window_start  INT UNSIGNED    NOT NULL,      -- unix epoch, window-floored
    hits          INT UNSIGNED    NOT NULL DEFAULT 1,
    PRIMARY KEY (id),
    UNIQUE KEY uq_rl_identity_window (identity, window_start)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Implementation

PDO data layer — one hardened connection factory; every query is a prepared statement.

<?php
declare(strict_types=1);

namespace App\Data;

use PDO;

final class Database
{
    public static function connect(): PDO
    {
        $dsn = sprintf(
            'mysql:host=%s;dbname=%s;charset=utf8mb4',
            getenv('DB_HOST') ?: 'db',
            getenv('DB_NAME') ?: 'shortener'
        );

        return new PDO($dsn, getenv('DB_USER') ?: 'app', getenv('DB_PASS') ?: '', [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES   => false, // real server-side prepares
        ]);
    }
}
<?php
declare(strict_types=1);

namespace App\Data;

use PDO;

final class LinkRepository
{
    public function __construct(private PDO $pdo) {}

    public function findBySlug(string $slug): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, destination, is_disabled FROM links WHERE slug = :slug LIMIT 1'
        );
        $stmt->execute(['slug' => $slug]);
        return $stmt->fetch() ?: null;
    }

    public function insert(string $slug, string $destination, ?int $userId, string $ip): void
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO links (slug, destination, created_by, created_ip)
             VALUES (:slug, :destination, :uid, :ip)'
        );
        $stmt->execute([
            'slug'        => $slug,
            'destination' => $destination,
            'uid'         => $userId,
            'ip'          => inet_pton($ip),
        ]);
    }

    public function incrementClicks(int $id): void
    {
        $this->pdo->prepare('UPDATE links SET click_count = click_count + 1 WHERE id = :id')
                  ->execute(['id' => $id]);
    }
}

Slug generation — cryptographically random, non-sequential, collision-retried.

<?php
declare(strict_types=1);

namespace App\Domain;

final class SlugGenerator
{
    private const ALPHABET = '23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'; // no 0/O/1/l

    public function generate(int $length = 7): string
    {
        $max  = strlen(self::ALPHABET) - 1;
        $slug = '';
        for ($i = 0; $i < $length; $i++) {
            $slug .= self::ALPHABET[random_int(0, $max)]; // CSPRNG
        }
        return $slug;
    }
}

URL validation — the heart of the open-redirect / SSRF defence.

<?php
declare(strict_types=1);

namespace App\Domain;

final class UrlValidator
{
    /** @return array{ok:bool, url?:string, error?:string} */
    public function validate(string $input): array
    {
        $url = trim($input);
        if ($url === '' || strlen($url) > 2048) {
            return ['ok' => false, 'error' => 'URL missing or too long'];
        }

        $parts = parse_url($url);
        if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
            return ['ok' => false, 'error' => 'Malformed URL'];
        }

        // Scheme allow-list — blocks javascript:, data:, file:, ftp: ...
        if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
            return ['ok' => false, 'error' => 'Only http/https allowed'];
        }

        $host = strtolower($parts['host']);

        // Resolve and reject private / loopback / link-local / reserved ranges (SSRF).
        foreach ($this->resolve($host) as $ip) {
            if (!filter_var($ip, FILTER_VALIDATE_IP,
                    FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                return ['ok' => false, 'error' => 'Destination host is not routable'];
            }
        }

        return ['ok' => true, 'url' => $url];
    }

    /** @return string[] */
    private function resolve(string $host): array
    {
        if (filter_var($host, FILTER_VALIDATE_IP)) {
            return [$host];
        }
        $records = array_merge(
            dns_get_record($host, DNS_A) ?: [],
            dns_get_record($host, DNS_AAAA) ?: []
        );
        return array_values(array_filter(array_map(
            static fn(array $r) => $r['ip'] ?? $r['ipv6'] ?? null, $records
        )));
    }
}
// VULNERABLE — trusting user input straight into the Location header:
// header('Location: ' . $_GET['url']);   // open redirect + header injection
// FIX: only ever redirect to a destination fetched from our own DB by slug,
// after it passed UrlValidator at creation time (see RedirectController).

CSRF tokens — synchronizer pattern with constant-time comparison.

<?php
declare(strict_types=1);

namespace App\Security;

final class Csrf
{
    public static function token(): string
    {
        if (empty($_SESSION['csrf'])) {
            $_SESSION['csrf'] = bin2hex(random_bytes(32));
        }
        return $_SESSION['csrf'];
    }

    public static function check(?string $sent): bool
    {
        return is_string($sent)
            && !empty($_SESSION['csrf'])
            && hash_equals($_SESSION['csrf'], $sent); // timing-safe
    }
}

Rate limiter — atomic fixed-window counter via INSERT ... ON DUPLICATE KEY UPDATE.

<?php
declare(strict_types=1);

namespace App\Security;

use PDO;

final class RateLimiter
{
    public function __construct(
        private PDO $pdo,
        private int $limit = 20,
        private int $window = 3600
    ) {}

    public function allow(string $identity): bool
    {
        $start = intdiv(time(), $this->window) * $this->window;
        $stmt  = $this->pdo->prepare(
            'INSERT INTO rate_limits (identity, window_start, hits)
             VALUES (:id, :ws, 1)
             ON DUPLICATE KEY UPDATE hits = hits + 1'
        );
        $stmt->execute(['id' => $identity, 'ws' => $start]);

        $count = $this->pdo->prepare(
            'SELECT hits FROM rate_limits WHERE identity = :id AND window_start = :ws'
        );
        $count->execute(['id' => $identity, 'ws' => $start]);
        return (int) $count->fetchColumn() <= $this->limit;
    }
}

Redirect controller + session hardening — the redirect endpoint only ever emits a destination it stored itself.

<?php
declare(strict_types=1);

namespace App\Http;

use App\Data\LinkRepository;

final class RedirectController
{
    public function __construct(private LinkRepository $links) {}

    public function handle(string $slug): void
    {
        if (!preg_match('/^[A-Za-z0-9]{1,16}$/', $slug)) {
            http_response_code(404);
            return;
        }
        $row = $this->links->findBySlug($slug);
        if ($row === null || (int) $row['is_disabled'] === 1) {
            http_response_code(404);
            return;
        }
        $this->links->incrementClicks((int) $row['id']);

        // Off-site interstitial: give the user a chance to bail on the jump.
        $destination = $row['destination'];
        require __DIR__ . '/../../templates/interstitial.php';
    }
}
// Session hardening (bootstrapped in public/index.php before session_start):
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_secure', '1');
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.use_strict_mode', '1');
// On privilege change (e.g. login): session_regenerate_id(true);

Output escaping in every template uses a single helper:

<?php
function e(string $v): string {
    return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
// Usage: <a href="<?= e($shortUrl) ?>"><?= e($shortUrl) ?></a>

Testing

Test the validator's decision boundaries (scheme allow-list, private-IP rejection), slug format/uniqueness, CSRF accept/reject, and the rate limiter crossing its threshold. Redirect handling should assert a 404 on unknown/disabled slugs and an interstitial on off-site targets.

<?php
declare(strict_types=1);

use App\Domain\UrlValidator;
use PHPUnit\Framework\TestCase;

final class UrlValidatorTest extends TestCase
{
    private UrlValidator $v;

    protected function setUp(): void
    {
        $this->v = new UrlValidator();
    }

    public function testRejectsNonHttpScheme(): void
    {
        self::assertFalse($this->v->validate('javascript:alert(1)')['ok']);
        self::assertFalse($this->v->validate('file:///etc/passwd')['ok']);
    }

    public function testRejectsPrivateAndLoopbackHosts(): void
    {
        self::assertFalse($this->v->validate('http://127.0.0.1/')['ok']);
        self::assertFalse($this->v->validate('http://169.254.169.254/latest/')['ok']);
        self::assertFalse($this->v->validate('http://10.0.0.5:6379/')['ok']);
    }

    public function testAcceptsPublicHttpsUrl(): void
    {
        self::assertTrue($this->v->validate('https://example.com/page?a=1')['ok']);
    }
}

Security Review

Risk OWASP 2021 Mitigation in this build
Open redirect / phishing chaining A01 Broken Access Control Redirect only to DB-stored, pre-validated destinations; off-site interstitial; no user input in Location
SSRF via link preview/fetch A10 SSRF Scheme allow-list; reject private/loopback/link-local/reserved IPs at create and fetch; re-resolve to blunt DNS rebinding
SQL injection A03 Injection 100% PDO prepared statements, EMULATE_PREPARES=false
Cross-site scripting A03 Injection htmlspecialchars(ENT_QUOTES) on all output; slug regex whitelist
CSRF on /shorten A01 Broken Access Control Synchronizer token, hash_equals, SameSite=Lax cookie
Abuse / spam flooding A04 Insecure Design Fixed-window RateLimiter keyed per user/IP; host blocklist
Enumeration of links A01 Broken Access Control CSPRNG non-sequential slugs; no ID exposure
Session hijack/fixation A07 Auth Failures HttpOnly+Secure+SameSite, strict mode, regenerate on login
Sensitive config leakage A05 Misconfiguration Secrets via env; only public/ web-exposed; errors logged not displayed

Deployment

Run locally with Docker Compose (nginx → php-fpm → MySQL). Only public/ is mapped as the web root, so no PHP source is directly reachable.

FROM php:8.3-fpm-alpine
RUN docker-php-ext-install pdo_mysql opcache
COPY docker/php-fpm.ini /usr/local/etc/php/conf.d/zz-app.ini
WORKDIR /var/www/html
COPY . .
server {
    listen 80;
    root /var/www/html/public;      # front controller only
    index index.php;
    location / { try_files $uri /index.php$is_args$args; }
    location ~ \.php$ {
        fastcgi_pass php:9000;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
    location ~ /\.(?!well-known) { deny all; }   # block dotfiles
}
composer install --no-dev --optimize-autoloader
docker compose up -d
docker compose exec db mysql -uapp -p shortener < migrations/001_init.sql
# App on http://localhost:8080 ; run tests with: ./vendor/bin/phpunit

Set DB_HOST/DB_NAME/DB_USER/DB_PASS in the compose environment; never bake credentials into the image. Terminate TLS at nginx or an upstream proxy so the Secure cookie flag is honoured.


Exercises

  1. Custom aliases — let authenticated users request a vanity slug; enforce the same regex whitelist, a reserved-word blocklist (admin, api, login), and the unique index.
  2. DNS-rebinding hardening — pin the resolved IP at creation and re-validate the same address at redirect time; reject if the host now resolves to a private range.
  3. Link expiry & one-time links — add expires_at and max_uses columns and enforce them in RedirectController.
  4. Owner dashboard — a GET /dashboard listing a user's links with click counts, gated by Role-Based-Access-Control and CSRF-protected disable/delete actions.
  5. Sliding-window limiter — replace the fixed-window counter with a token-bucket or Redis sorted-set limiter and compare abuse resistance.

References

  • OWASP Cheat Sheet — Unvalidated Redirects and Forwards
  • OWASP Cheat Sheet — Server-Side Request Forgery Prevention
  • OWASP Top 10:2021 — A01, A03, A04, A10
  • PHP Manual — PDO Prepared Statements, random_int, filter_var, parse_url, hash_equals
  • PHP Manual — Session Security / runtime configuration

Related