critical

CVE

CVE-2026-115931, CVE-2026-115932, CVE-2026-115933

CWE

CWE-470, CWE-73, CWE-22, CWE-345, CWE-347

Affected Surface

  • GitroomHQ `postiz-app` (`gitroomhq/postiz-app` and `ghcr.io/gitroomhq/postiz-app`) versions `>= 2.23.0, < 2.25.2` for `CVE-2026-115931` (`PSA-2026-JNXM56`)
  • GitroomHQ `postiz-app` versions `>= 2.23.0, < 2.25.3` for `CVE-2026-115932` (`PSA-2026-E9AHFV`)
  • GitroomHQ `postiz-app` versions `>= 2.15.0, < 2.25.3` for `CVE-2026-115933` (`PSA-2026-YT2HMH`)
  • Self-hosted Postiz deployments with default open registration (`DISABLE_REGISTRATION` not set to `true`), where any visitor can create an account and reach all three endpoints

TL;DR

On 23 and 24 October 2026, GitroomHQ released postiz-app v2.25.2 and v2.25.3 to patch three critical vulnerabilities (CVE-2026-115931, CVE-2026-115932, and CVE-2026-115933) reported by Anthropic and coordinated through GAdvisory (PSA-2026-JNXM56, PSA-2026-E9AHFV, and PSA-2026-YT2HMH).

Postiz is a widely self-hosted TypeScript / NestJS social-media automation and AI scheduling platform (~28k GitHub stars). By default, Postiz leaves self-registration open (DISABLE_REGISTRATION is unset) and runs its container processes as root. Any visitor who registers a free account can exploit all three bugs without administrator interaction:

  1. CVE-2026-115931 (PSA-2026-JNXM56, CVSS 9.9 Critical): POST /integrations/function invoked integrationProvider[body.name](integration.token, body.data, integration.internalId) without checking whether body.name was an intended dashboard helper. A user who connected a channel whose stored token was a filesystem path (such as /proc/self/environ) could invoke SocialAbstract.readOrFetch directly, returning the backend’s environment variables (JWT_SECRET, DATABASE_URL, and OAuth client secrets) in the HTTP response. Calling another inherited helper terminated the Node.js process in a single request.
  2. CVE-2026-115932 (PSA-2026-E9AHFV, CVSS 9.6 Critical): When publishing or validating a post, SocialAbstract.readOrFetch(), fetchLength(), TelegramProvider, TiktokProvider, and YoutubeProvider treated any media path that did not begin with http as a local filesystem path and passed it straight to readFileSync(path) or createReadStream(path) without checking that it resolved inside UPLOAD_DIRECTORY. An attacker could attach /proc/self/environ or any root-readable file to a scheduled post and receive the file byte-for-byte in a Discord channel they controlled.
  3. CVE-2026-115933 (PSA-2026-YT2HMH, CVSS 9.9 Critical): Postiz signed login sessions, team invitations, password-reset links, and enterprise provisioning payloads with a single JWT_SECRET (AuthService.signJWT) and no token-type claim (typ / aud). Worse, two routes acted as arbitrary JWT signing oracles: POST /integrations/extension-refresh called AuthService.signJWT(JSON.parse(Buffer.from(body.cookies, 'base64').toString())) and saved the resulting JWT into the caller’s readable refreshToken column, while inviteTeamMember() spread { ...body, orgId: org.id, timeLimit, id } into AuthService.signJWT(). Any user could mint a valid login or super-admin invitation token for any user or organization.

What to do now:

  1. Upgrade postiz-app to v2.25.3 or later (ghcr.io/gitroomhq/postiz-app:v2.25.3). Note that v2.25.2 only patches CVE-2026-115931 and remains vulnerable to CVE-2026-115932 and CVE-2026-115933.
  2. Set DISABLE_REGISTRATION=true on any internet-facing Postiz instance that does not intentionally offer public sign-ups.
  3. Because both CVE-2026-115931 and CVE-2026-115932 disclose /proc/self/environ and CVE-2026-115933 mints valid tokens under JWT_SECRET, rotate JWT_SECRET, DATABASE_URL, MAIN_PASS, and all third-party OAuth / API credentials after upgrading.

Affected packages and projects

EcosystemProject / imageCVE / AdvisorySeverityAffected versionsFixed versionVulnerable component
GitHub / Docker (ghcr.io/gitroomhq/postiz-app)gitroomhq/postiz-appCVE-2026-115931 (PSA-2026-JNXM56)CVSS 9.9 Critical>= 2.23.0, < 2.25.22.25.2apps/backend/src/api/routes/integrations.controller.ts (POST /integrations/function and /integrations/mentions)
GitHub / Docker (ghcr.io/gitroomhq/postiz-app)gitroomhq/postiz-appCVE-2026-115932 (PSA-2026-E9AHFV)CVSS 9.6 Critical>= 2.23.0, < 2.25.32.25.3libraries/nestjs-libraries/src/integrations/social.abstract.ts (readOrFetch, fetchLength) and provider media readers
GitHub / Docker (ghcr.io/gitroomhq/postiz-app)gitroomhq/postiz-appCVE-2026-115933 (PSA-2026-YT2HMH)CVSS 9.9 Critical>= 2.15.0, < 2.25.32.25.3apps/backend/src/api/routes/no.auth.integrations.controller.ts (extension-refresh) and organization.service.ts (inviteTeamMember)

Technical analysis

1. CVE-2026-115931: arbitrary method dispatch on integrationProvider[body.name]

In Postiz, social-media integrations (FacebookProvider, InstagramProvider, LinkedinProvider, YoutubeProvider, etc.) inherit from SocialAbstract (libraries/nestjs-libraries/src/integrations/social.abstract.ts). When the web UI needs provider-specific metadata such as a list of Facebook pages, LinkedIn companies, or Hashnode tags, it sends a POST /integrations/function request containing { id, name, data }.

Before commit 9383f5be4d, IntegrationsController.functionIntegration() in apps/backend/src/api/routes/integrations.controller.ts looked up integrationProvider[body.name] on the provider instance without any allowlist or decorator check:

// apps/backend/src/api/routes/integrations.controller.ts (vulnerable)
const integrationProvider = this._integrationManager.getSocialIntegration(
  integration.providerIdentifier
);
if (!integrationProvider) {
  throw new Error('Invalid provider');
}

// @ts-ignore
if (integrationProvider[body.name]) {
  try {
    // @ts-ignore
    const load = await integrationProvider[body.name](
      integration.token,
      body.data,
      integration.internalId,
      integration
    );
    return load;
  } catch (err) {
    ...
  }
}

Notice that integration.token is passed as the first argument to whatever method name the caller supplies in body.name. Every provider inherits protected async readOrFetch(path: string) from SocialAbstract, which is a plain JavaScript method on the prototype at runtime:

// libraries/nestjs-libraries/src/integrations/social.abstract.ts (vulnerable)
protected async readOrFetch(path: string, retried = false): Promise<Buffer> {
  if (path.indexOf('http') !== 0) {
    return readFileSync(path);
  }
  ...
}

When a user connects a provider channel where they control the stored token string (or sets it to a local file path such as /proc/self/environ or /etc/passwd) and sends POST /integrations/function with {"id": "<integrationId>", "name": "readOrFetch", "data": false}, integrationProvider['readOrFetch'](integration.token, ...) calls readFileSync('/proc/self/environ') and returns the raw Buffer in the HTTP response. That single read exposes JWT_SECRET, DATABASE_URL, and every configured provider secret. In addition, /integrations/mentions forwarded the caller’s body directly into this.functionIntegration(org, body) without forcing body.name = 'mention'.

The fix in 9383f5be4d adds a @DashboardFunction() metadata decorator (custom:dashboard_function), decorates only the legitimate dashboard helpers (mention, pages, companies, company, and tags), and gates POST /integrations/function through IntegrationManager.isDashboardCallable():

--- a/apps/backend/src/api/routes/integrations.controller.ts
+++ b/apps/backend/src/api/routes/integrations.controller.ts
@@ -309,7 +309,9 @@ export class IntegrationsController {
     let newList: any[] | { none: true } = [];
     try {
-      newList = (await this.functionIntegration(org, body)) || [];
+      newList =
+        (await this.functionIntegration(org, { ...body, name: 'mention' })) ||
+        [];
     } catch (err) {
       console.log(err);
     }
@@ -370,8 +372,12 @@ export class IntegrationsController {
-    // @ts-ignore
-    if (integrationProvider[body.name]) {
+    if (
+      this._integrationManager.isDashboardCallable(
+        integrationProvider,
+        body.name
+      )
+    ) {
       try {
         // @ts-ignore
         const load = await integrationProvider[body.name](

2. CVE-2026-115932: post media path traversal outside UPLOAD_DIRECTORY

When a scheduled post with media attachments is validated or published, Postiz resolves each attachment’s path field. If path starts with http, SocialAbstract.readOrFetch(path) fetches the URL; otherwise, it assumes path is a local file inside the server’s upload directory.

Before commit 12db9bd2f4, neither SocialAbstract.readOrFetch(), SocialAbstract.fetchLength(), TelegramProvider, TiktokProvider, YoutubeProvider, nor LocalStorage.removeFile() verified that a non-HTTP path resolved inside process.env.UPLOAD_DIRECTORY:

--- a/libraries/nestjs-libraries/src/integrations/social.abstract.ts
+++ b/libraries/nestjs-libraries/src/integrations/social.abstract.ts
@@ -276,7 +277,7 @@ export abstract class SocialAbstract {
   protected async readOrFetch(path: string, retried = false): Promise<Buffer> {
     if (path.indexOf('http') !== 0) {
-      return readFileSync(path);
+      return readFileSync(this.localMediaPath(path));
     }
...
+  protected localMediaPath(path: string, identifier = '') {
+    const base = process.env.UPLOAD_DIRECTORY
+      ? resolve(process.env.UPLOAD_DIRECTORY)
+      : '';
+    const filePath = resolve(path);
+    if (!base || !filePath.startsWith(base + sep)) {
+      throw new BadBody(
+        identifier,
+        '{}',
+        Buffer.from('Invalid media path'),
+        'Invalid media path'
+      );
+    }
+    return filePath;
+  }

As GAdvisory’s reproduction (PSA-2026-E9AHFV) showed, a self-registered user could submit a post with a local filesystem path (or save a media record via MediaController and traverse out of UPLOAD_DIRECTORY) and schedule it to a Discord channel they controlled. When the orchestrator worker ran the job, readOrFetch() read the target file from disk and uploaded it as a Discord attachment. The same commit also hardens MediaController.saveMedia() to reject any name containing / or \ or equal to . / .., and upgrades Dockerfile.dev from node:22.20-bookworm-slim to node:22.23.4-bookworm-slim.

3. CVE-2026-115933: extension-refresh and inviteTeamMember JWT signing oracles

Postiz uses AuthService.signJWT(value) and AuthService.verifyJWT(token) (wrapping jsonwebtoken with process.env.JWT_SECRET) across five completely different trust boundaries:

  • Login session cookies (auth): expects { id: string, ... } in AuthMiddleware (if (!payload?.id))
  • Team invitation links (/?org=...): expects { id, email, role, orgId, timeLimit } in AuthService.addTeamMember()
  • Password-reset tokens: expects { id, expires } in AuthService.changePassword()
  • Subscription modification tokens: expects { orgId, billing } in PublicController
  • Enterprise provisioning tokens: expects { apiKey, id, redirectUrl, provider } in EnterpriseController

Because all five token types shared one HMAC secret and carried no typ or aud discriminator, any code path that passed attacker-influenced JSON to AuthService.signJWT() and returned or stored the resulting string became a universal token-minting oracle.

Commit a5affe62c7 fixes two such signing paths:

  1. Browser-extension credential refresh (POST /integrations/extension-refresh in no.auth.integrations.controller.ts): When refreshing a Skool channel via the browser extension, the route decoded body.cookies from Base64, parsed it as JSON, signed the arbitrary JSON object with AuthService.signJWT(), and stored the resulting JWT string in the integration’s refreshToken column:
    --- a/apps/backend/src/api/routes/no.auth.integrations.controller.ts
    +++ b/apps/backend/src/api/routes/no.auth.integrations.controller.ts
    @@ -428,8 +443,10 @@ export class NoAuthIntegrationsController {
           false,
           undefined,
           undefined,
    -      AuthService.signJWT(
    -        JSON.parse(Buffer.from(body.cookies, 'base64').toString())
    +      // stored encrypted like connectSocialMedia does, never signed: the
    +      // cookies JSON comes from the request
    +      AuthService.fixedEncryption(
    +        Buffer.from(body.cookies, 'base64').toString()
           )
         );
    Because other integration endpoints returned the integration row (including refreshToken) to the owning user, an attacker who passed Base64-encoded {"id": "<victimUserId>"} or {"orgId": "<victimOrgId>", "role": "SUPERADMIN", "timeLimit": "2099-01-01", "id": "x"} in body.cookies received back a validly signed JWT accepted by AuthMiddleware or the team-invite consumer.
  2. Object spread in OrganizationService.inviteTeamMember(): The team-invitation route signed { ...body, orgId: org.id, timeLimit, id } instead of picking only email and role:
    --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts
    +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts
    @@ -103,9 +103,16 @@ export class OrganizationService {
       async inviteTeamMember(org: Organization, user: User, body: AddTeamMemberDto) {
         const timeLimit = dayjs().add(2, 'day').format('YYYY-MM-DD HH:mm:ss');
         const id = makeId(5);
    +    // only the invite fields, the body can carry undeclared properties
         const url =
           process.env.FRONTEND_URL +
    -      `/?org=${AuthService.signJWT({ ...body, orgId: org.id, timeLimit, id })}`;
    +      `/?org=${AuthService.signJWT({
    +        email: body.email,
    +        role: body.role,
    +        orgId: org.id,
    +        timeLimit,
    +        id,
    +      })}`;

The patch also replaces AuthService.signJWT() in extension-refresh with AuthService.fixedEncryption(), adds select: { id: true } to IntegrationRepository update queries so refreshToken is not echoed back, and adds cross-type claim rejection ('timeLimit' in payload, 'expires' in payload, 'providerName' in load) across the token consumers.

Detection and remediation

1. Check deployed Postiz versions, open registration, and suspicious database records

Run the following checks against your containers, Compose/Helm manifests, and PostgreSQL database:

# 1. Check Postiz image tags and running container versions
rg -n 'gitroomhq/postiz-app' docker-compose*.y*ml compose*.y*ml k8s helm .github 2>/dev/null
docker ps --format '{{.Names}}\t{{.Image}}' | grep -i postiz || true

# 2. Check whether self-registration is disabled in your environment
rg -n 'DISABLE_REGISTRATION' .env* docker-compose*.y*ml compose*.y*ml k8s helm 2>/dev/null

# 3. Audit HTTP access logs for exploit paths
rg -n '/integrations/(function|mentions|extension-refresh)' /var/log/nginx/*access* /var/log/traefik/* 2>/dev/null

If you have access to the Postiz PostgreSQL database, inspect Integration and Media rows for local filesystem paths (CVE-2026-115931 and CVE-2026-115932) or eyJ... JWT strings stored in refreshToken:

-- Check for integrations whose token looks like a local path or whose refreshToken is a raw JWT
SELECT id, "organizationId", "providerIdentifier", "createdAt"
FROM "Integration"
WHERE token LIKE '/%' OR token LIKE '..%' OR "refreshToken" LIKE 'eyJ%';

-- Check Media rows for path traversal or non-upload paths
SELECT id, "organizationId", path, "createdAt"
FROM "Media"
WHERE path LIKE '/%' OR path LIKE '%..%';

2. Upgrade to v2.25.3, lock down registration, and rotate secrets

  1. Upgrade immediately to postiz-app v2.25.3 or later (commit a5affe62c7).
  2. Disable public self-registration on private or internal deployments by setting DISABLE_REGISTRATION="true" in your environment or docker-compose.yaml.
  3. Rotate JWT_SECRET and all service credentials: Because an attacker who exploited CVE-2026-115931 or CVE-2026-115932 could read /proc/self/environ (and an attacker who exploited CVE-2026-115933 could mint persistent session JWTs), generate a fresh JWT_SECRET to invalidate all existing sessions and invitations, and rotate DATABASE_URL, MAIN_PASS, and any social-provider OAuth client secrets configured on the host.

Timeline

  • 2026-10-23 (21:28 UTC): GitroomHQ commits 9383f5be4d (“fix(integrations): only run allowed provider methods from /integrations/function”) and tags v2.25.2.
  • 2026-10-23 (22:02 UTC): CVE-2026-115931 (PSA-2026-JNXM56, CVSS 9.9 Critical) is published.
  • 2026-10-24 (08:38 UTC): GitroomHQ commits 12db9bd2f4 (“fix: access to local files”) adding SocialAbstract.localMediaPath() and UPLOAD_DIRECTORY containment.
  • 2026-10-24 (12:28 UTC): CVE-2026-115932 (PSA-2026-E9AHFV, CVSS 9.6 Critical) is published.
  • 2026-10-24 (13:27 to 14:17 UTC): GitroomHQ commits a5affe62c7 (“fix: jwt bypass”), tags v2.25.3, and publishes CVE-2026-115933 (PSA-2026-YT2HMH, CVSS 9.9 Critical).

From research to remediation

Check whether this pattern exists in your codebase

Turn this research into a remediation workflow. Use AI-native static analysis to find similar application vulnerabilities and generate review-ready fixes.

References