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.
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/httpsonly), host validation, and an interstitial warning for off-site jumps. - SSRF via link previews — a stored target of
http://169.254.169.254/orhttp://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).
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
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.
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
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;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>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']);
}
}| 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 |
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/phpunitSet 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.
- 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. - 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.
- Link expiry & one-time links — add
expires_atandmax_usescolumns and enforce them inRedirectController. - Owner dashboard — a
GET /dashboardlisting a user's links with click counts, gated by Role-Based-Access-Control and CSRF-protected disable/delete actions. - Sliding-window limiter — replace the fixed-window counter with a token-bucket or Redis sorted-set limiter and compare abuse resistance.
- 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
- Prepared-Statements — the PDO pattern every query in the data layer relies on
- CSRF-Tokens — synchronizer token used to protect
POST /shorten - Role-Based-Access-Control — gates the owner dashboard extension
- Building-a-CRUD-API — the same routing/PDO/escaping spine in JSON form
- Docker-for-PHP — the nginx + php-fpm + MySQL stack used to run this
- OWASP-Top-10-2021 — risk taxonomy the Security Review maps against