Shlink version
5.0.2
PHP version
8.4.20
How do you serve Shlink
Docker image
Database engine
PostgreSQL
Database version
18.1
Current behavior
A GET request whose path contains bytes that are not valid UTF-8 makes Shlink pass
the raw, undecoded bytes to Postgres as the short-code query parameter. Postgres
rejects the parameter and Shlink returns a 500:
Shlink.ERROR - PDOException: SQLSTATE[22021]: Character not in repertoire: 7
ERROR: invalid byte sequence for encoding "UTF8": 0xc0
CONTEXT: unnamed portal parameter $1
in /etc/shlink/vendor/doctrine/dbal/src/Driver/PDO/Exception.php:24
#0 .../doctrine/dbal/src/Driver/PDO/Statement.php(57)
Also reproduced on 5.1.6 (PHP 8.5.7) and on Postgres 16.14 — same result on every
combination.
This is not only scanner noise. We see two traffic sources in production:
- Vulnerability scanners probing paths like
/%C0.
- Real users: some in-app browsers (Facebook/Instagram,
fbclid present)
occasionally append a mangled invisible Unicode character to shared short
links; the bytes arrive invalid and the visitor gets a 500 instead of the
not-found redirect. Each hit also fires 5xx-based alerting, indistinguishable
from a genuine outage.
NOTE: I did use claude to help reproduce and diagnose the issue. I wanted to make sure everything was fully tested before writing any tickets.
Expected behavior
Since short codes are drawn from an ASCII alphabet, a path containing invalid
UTF-8 can never match a short URL — the expected response is a 404 (or the
configured not-found redirect), not a 500. Validating/decoding the short-code
path segment as UTF-8 (or restricting it to the short-code alphabet) before it
reaches the DB layer would short-circuit these to the regular not-found handling.
Minimum steps to reproduce
Self-contained docker-compose (official images only, no cloud components):
services:
shlink:
image: shlinkio/shlink:5.1.6
ports: ["8090:8080"]
environment:
DEFAULT_DOMAIN: "localhost:8090"
IS_HTTPS_ENABLED: "false"
DB_DRIVER: postgres
DB_HOST: db
DB_NAME: shlink
DB_USER: shlink
DB_PASSWORD: shlink
depends_on: { db: { condition: service_healthy } }
db:
image: postgres:16
environment:
POSTGRES_DB: shlink
POSTGRES_USER: shlink
POSTGRES_PASSWORD: shlink
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shlink"]
interval: 2s
timeout: 3s
retries: 20
docker compose up -d
curl -i 'http://localhost:8090/%C0'
# → HTTP 500; shlink log shows:
# SQLSTATE[22021] ... invalid byte sequence for encoding "UTF8": 0xc0
Any invalid byte works (%FF, %C0, or raw bytes). Valid multi-byte UTF-8
(e.g. /%E2%81%A3) is handled fine — only undecodable sequences trigger it.
Shlink version
5.0.2
PHP version
8.4.20
How do you serve Shlink
Docker image
Database engine
PostgreSQL
Database version
18.1
Current behavior
A GET request whose path contains bytes that are not valid UTF-8 makes Shlink pass
the raw, undecoded bytes to Postgres as the short-code query parameter. Postgres
rejects the parameter and Shlink returns a 500:
Also reproduced on 5.1.6 (PHP 8.5.7) and on Postgres 16.14 — same result on every
combination.
This is not only scanner noise. We see two traffic sources in production:
/%C0.fbclidpresent)occasionally append a mangled invisible Unicode character to shared short
links; the bytes arrive invalid and the visitor gets a 500 instead of the
not-found redirect. Each hit also fires 5xx-based alerting, indistinguishable
from a genuine outage.
NOTE: I did use claude to help reproduce and diagnose the issue. I wanted to make sure everything was fully tested before writing any tickets.
Expected behavior
Since short codes are drawn from an ASCII alphabet, a path containing invalid
UTF-8 can never match a short URL — the expected response is a 404 (or the
configured not-found redirect), not a 500. Validating/decoding the short-code
path segment as UTF-8 (or restricting it to the short-code alphabet) before it
reaches the DB layer would short-circuit these to the regular not-found handling.
Minimum steps to reproduce
Self-contained docker-compose (official images only, no cloud components):
Any invalid byte works (
%FF,%C0, or raw bytes). Valid multi-byte UTF-8(e.g.
/%E2%81%A3) is handled fine — only undecodable sequences trigger it.