All articles

Why link shorteners get abused, and what a responsible one does about it

A shortener is an open redirect with a business model. Phishing, SSRF, SEO laundering: the real attacks, the defences that work, and their honest limits.

11 min readBy gecis.in engineering
  • security
  • abuse
  • links

A link shortener is, structurally, an open redirect that you run on purpose. Somebody hands you a URL, you give them a short one on your domain, and from then on your domain vouches for their destination to every person who sees the link.

That last clause is the whole problem. The value a shortener provides to an attacker is not brevity. It is borrowed trust plus concealment: your reputable domain in front of their destination, and the destination invisible until the click happens.

Here is what actually gets attempted, what defends against it, and — the part that matters most — where each defence stops working.

The attacks

Phishing and brand impersonation

The bulk of it. A short link in an SMS or an email hides secure-bank-login-verify.example.ru behind a hostname that email filters and humans both treat as neutral. Every mass-market shortener has been used this way; it is not a sign of a badly run service, it is the baseline cost of operating one.

The important consequence is not that individual users get phished. It is what happens next.

Domain reputation is the real asset

This is the thing to understand before any of the technical controls make sense.

If enough abusive links go out on your domain, the domain gets listed — by Google Safe Browsing, by Microsoft SmartScreen, by corporate mail gateways, by mobile carriers' SMS filters, by DNS filtering services. Those systems block at the domain level, because they cannot enumerate your slugs and because your slugs are attacker-controlled anyway.

When that happens, every link on your domain stops working. Not the abusive ones. All of them. Every customer's campaign, every QR code already printed on a poster, every link in an email sent last week. There is no partial failure mode and there is no fast appeal process.

So the honest framing of abuse prevention for a shortener is not "protecting users from bad actors". It is: your domain is a single shared reputation asset and every free link you hand out is a bet on it. That reframing explains why the defences look paranoid relative to the size of the feature.

SEO laundering

Search engines follow redirects. A shortener that lets its redirect endpoints be crawled and indexed becomes a way to launder link equity and to get spam pages ranked under a reputable domain. The fix is one header, on every response including the 404s:

X-Robots-Tag: noindex, nofollow

robots.txt is not sufficient here — it cannot enumerate slugs, and it is advisory. The header is on the redirect itself.

SSRF, if you fetch the target

Most shorteners want to fetch the destination: to render a preview card, grab a favicon, check that the URL is alive, or scan it. The moment your server makes a request to a user-supplied URL, you have built a Server-Side Request Forgery primitive, and the attacker's first target is not the internet — it is your own network.

http://169.254.169.254/latest/meta-data/iam/security-credentials/
http://10.0.0.20:5432/
http://[::1]:6379/
http://localhost/admin/

The link-local address is cloud instance metadata. On an unhardened deployment that one request is a credential disclosure.

gecis.in never fetches the target — the browser does, after the redirect, and it never hands us the response. That removes the whole class. We still validate against private ranges, for two reasons: a link pointing at 10.0.0.20 is never legitimate and is a strong signal of who created it, and we want the invariant to already be there on the day someone adds a preview feature.

Redirect chains and loops

A shortener that accepts another shortener's URL becomes a link in a chain that automated scanners cannot resolve and humans certainly cannot. A shortener that accepts its own URLs can be made to loop, or used to bury the true destination behind twenty hops on the same trusted domain.

The defences we actually run

Target validation, syntactically

// lib/target-url.ts
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
  return { ok: false, reason: 'unsupported_scheme' }
}
// `http://user:pass@host` smuggles a different authority past a human reader.
if (url.username !== '' || url.password !== '') {
  return { ok: false, reason: 'credentials_not_allowed' }
}

Scheme allow-list first. Not a deny-list — javascript:, data:, file:, intent:, vbscript: and whatever ships next year all fail one allow-list check and would each need their own deny-list entry.

The credentials check is worth calling out because it is unglamorous and it stops a real trick. https://[email protected]/ is a URL whose host is evil.example; everything before the @ is a username. It has been used in phishing for twenty years and still works on people, so the URL simply does not get accepted.

Self-reference is rejected too — a shortener pointing at itself is a loop or a laundering attempt, never a use case.

Private-range rejection, including through DNS

Literal addresses are checked against a block list; hostnames are resolved first and the resolved addresses checked the same way.

const BLOCKED_V4 = [
  '0.0.0.0/8',
  '10.0.0.0/8',
  '100.64.0.0/10', // CGNAT
  '127.0.0.0/8',
  '169.254.0.0/16', // link-local: cloud metadata lives here
  '172.16.0.0/12',
  '192.0.0.0/24',
  '192.0.2.0/24',
  '192.168.0.0/16',
  '198.18.0.0/15',
  '198.51.100.0/24',
  '203.0.113.0/24',
  '224.0.0.0/4',
  '240.0.0.0/4',
]

IPv6 needs its own logic and is where most implementations are incomplete: ::1, fc00::/7 unique-local, fe80::/10 link-local, ff00::/8 multicast, and — the one people forget — IPv4-mapped addresses like ::ffff:127.0.0.1, which must be unwrapped and judged as the IPv4 address they contain.

Two design rules make the difference between this working and merely existing:

Fail closed. Anything that cannot be parsed is treated as private. A hostname that does not resolve is rejected, not accepted.

export function isPrivateAddress(ip: string): boolean {
  const family = isIP(ip)
  if (family === 4) return isPrivateV4(ip)
  if (family === 6) return isPrivateV6(ip)
  return true // unparseable -> private. Never fail open.
}

Be honest about DNS rebinding. We resolve at creation time. Nothing stops an attacker from pointing the hostname at a public address, getting the link created, and repointing it at 10.0.0.20 a minute later. Any check that resolves at time-of-check and connects at time-of-use is vulnerable to this, and "we validate the target" is a claim that quietly assumes otherwise.

The reason it is not exploitable here is architectural rather than clever: we never connect. The check exists to keep junk out of the database and to hold the line for a future feature; it is not load-bearing for SSRF, because nothing in the request path makes an outbound request at all. If you do fetch targets, validation at creation time is not enough — you need to pin the resolved address and connect to that, or route the fetch through an egress proxy that enforces the policy at connect time.

Rate limits

Ten links per hour, fifty per day, per client IP. Modest numbers, deliberately. Abuse campaigns are volume businesses; a limit that is comfortable for a human and inconvenient for a script is doing its job even when it is trivially bypassable with a proxy pool, because it converts free abuse into abuse with a cost.

The limit is stored as a hash of the creator's IP, so the links table never holds a raw address. That has a wrinkle involving the daily salt rotation which we wrote about in a separate post — it is the sort of interaction between two reasonable designs that only shows up when you read both at once.

CAPTCHA, failing closed

Link creation goes through Cloudflare Turnstile. The important detail is not the widget, it is what happens when verification cannot complete:

The tempting alternative — let it through when the verifier is down, so the product keeps working — turns a Cloudflare incident into an open bulk-submission window, and attackers watch for exactly those windows. The process also refuses to boot in production if either Turnstile key is missing, so a misconfigured deploy cannot silently ship an unprotected form.

An abuse reporting path that a human reads

There is a /abuse form, it writes to a table with a handled_at column, and the address is published. This is not a compliance checkbox. Reputation providers and mail operators care whether reports get a response, and a shortener with no working abuse contact accumulates listings it never finds out about until the domain stops resolving through somebody's DNS filter.

What we do not do yet, and what it would cost

Safe Browsing lookups. Google's Safe Browsing API is the obvious next control, and it comes in two flavours with a real trade-off:

  • The Lookup API sends the URL to Google and gets a verdict. Simple, and it means every URL your users shorten is transmitted to a third party. For a service whose pitch includes self-hosting and data residency, that is a contradiction we are not willing to ship quietly.
  • The Update API downloads hash prefixes of known-bad URL expressions and checks locally, contacting Google only for a full-hash confirmation when a prefix matches. Privacy-preserving, materially more work: a local database, a scheduled update job, canonicalisation of URLs exactly to spec, and the failure modes of a stale list.

We intend to do the second. Saying "we scan links with Safe Browsing" before that is built would be the kind of claim this blog is supposed to avoid.

Interstitials. Showing the destination on a warning page before redirecting is genuinely effective against concealment — it removes the borrowed trust, because the user sees the real hostname. It also breaks QR codes, adds a full page load to every click, and destroys the reason people use a shortener. The defensible version is conditional: interstitial only for links that trip a heuristic. That requires the heuristics to exist first.

The limit nobody engineers around

Everything above validates the target at creation time. A URL that is clean today can be repointed tomorrow: the attacker shortens their own harmless domain, waits for the link to propagate into an email campaign or a printed QR code, and then changes what that domain serves.

No amount of creation-time validation touches this. The only real defences are operational:

  • Re-scan. Periodically re-check live targets, not just new ones.
  • Signals from the click stream. A link whose traffic pattern changes shape — a dormant slug that suddenly takes thousands of clicks from one referrer — is worth looking at, and this is one of the few genuinely good arguments for keeping detailed click data.
  • Fast takedown. is_active = false on the row, and the redirect returns 404 immediately. Minutes, not a ticket queue.

Which is the uncomfortable conclusion: for a link shortener, abuse prevention is not a feature you ship. It is an operational commitment you take on for as long as the domain exists, and the technical controls exist mostly to keep the volume low enough that a human can handle what gets through.

Articles are published in English.

gecis.in Blog · RSS