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:
CVE-2026-115931(PSA-2026-JNXM56, CVSS 9.9 Critical):POST /integrations/functioninvokedintegrationProvider[body.name](integration.token, body.data, integration.internalId)without checking whetherbody.namewas an intended dashboard helper. A user who connected a channel whose storedtokenwas a filesystem path (such as/proc/self/environ) could invokeSocialAbstract.readOrFetchdirectly, 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.CVE-2026-115932(PSA-2026-E9AHFV, CVSS 9.6 Critical): When publishing or validating a post,SocialAbstract.readOrFetch(),fetchLength(),TelegramProvider,TiktokProvider, andYoutubeProvidertreated any mediapaththat did not begin withhttpas a local filesystem path and passed it straight toreadFileSync(path)orcreateReadStream(path)without checking that it resolved insideUPLOAD_DIRECTORY. An attacker could attach/proc/self/environor any root-readable file to a scheduled post and receive the file byte-for-byte in a Discord channel they controlled.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 singleJWT_SECRET(AuthService.signJWT) and no token-type claim (typ/aud). Worse, two routes acted as arbitrary JWT signing oracles:POST /integrations/extension-refreshcalledAuthService.signJWT(JSON.parse(Buffer.from(body.cookies, 'base64').toString()))and saved the resulting JWT into the caller’s readablerefreshTokencolumn, whileinviteTeamMember()spread{ ...body, orgId: org.id, timeLimit, id }intoAuthService.signJWT(). Any user could mint a valid login or super-admin invitation token for any user or organization.
What to do now:
- Upgrade
postiz-apptov2.25.3or later (ghcr.io/gitroomhq/postiz-app:v2.25.3). Note thatv2.25.2only patchesCVE-2026-115931and remains vulnerable toCVE-2026-115932andCVE-2026-115933. - Set
DISABLE_REGISTRATION=trueon any internet-facing Postiz instance that does not intentionally offer public sign-ups. - Because both
CVE-2026-115931andCVE-2026-115932disclose/proc/self/environandCVE-2026-115933mints valid tokens underJWT_SECRET, rotateJWT_SECRET,DATABASE_URL,MAIN_PASS, and all third-party OAuth / API credentials after upgrading.
Affected packages and projects
| Ecosystem | Project / image | CVE / Advisory | Severity | Affected versions | Fixed version | Vulnerable component |
|---|---|---|---|---|---|---|
GitHub / Docker (ghcr.io/gitroomhq/postiz-app) | gitroomhq/postiz-app | CVE-2026-115931 (PSA-2026-JNXM56) | CVSS 9.9 Critical | >= 2.23.0, < 2.25.2 | 2.25.2 | apps/backend/src/api/routes/integrations.controller.ts (POST /integrations/function and /integrations/mentions) |
GitHub / Docker (ghcr.io/gitroomhq/postiz-app) | gitroomhq/postiz-app | CVE-2026-115932 (PSA-2026-E9AHFV) | CVSS 9.6 Critical | >= 2.23.0, < 2.25.3 | 2.25.3 | libraries/nestjs-libraries/src/integrations/social.abstract.ts (readOrFetch, fetchLength) and provider media readers |
GitHub / Docker (ghcr.io/gitroomhq/postiz-app) | gitroomhq/postiz-app | CVE-2026-115933 (PSA-2026-YT2HMH) | CVSS 9.9 Critical | >= 2.15.0, < 2.25.3 | 2.25.3 | apps/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, ... }inAuthMiddleware(if (!payload?.id)) - Team invitation links (
/?org=...): expects{ id, email, role, orgId, timeLimit }inAuthService.addTeamMember() - Password-reset tokens: expects
{ id, expires }inAuthService.changePassword() - Subscription modification tokens: expects
{ orgId, billing }inPublicController - Enterprise provisioning tokens: expects
{ apiKey, id, redirectUrl, provider }inEnterpriseController
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:
- Browser-extension credential refresh (
POST /integrations/extension-refreshinno.auth.integrations.controller.ts): When refreshing a Skool channel via the browser extension, the route decodedbody.cookiesfrom Base64, parsed it as JSON, signed the arbitrary JSON object withAuthService.signJWT(), and stored the resulting JWT string in the integration’srefreshTokencolumn:
Because other integration endpoints returned the integration row (including--- 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() ) );refreshToken) to the owning user, an attacker who passed Base64-encoded{"id": "<victimUserId>"}or{"orgId": "<victimOrgId>", "role": "SUPERADMIN", "timeLimit": "2099-01-01", "id": "x"}inbody.cookiesreceived back a validly signed JWT accepted byAuthMiddlewareor the team-invite consumer. - Object spread in
OrganizationService.inviteTeamMember(): The team-invitation route signed{ ...body, orgId: org.id, timeLimit, id }instead of picking onlyemailandrole:--- 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
- Upgrade immediately to
postiz-appv2.25.3or later (commita5affe62c7). - Disable public self-registration on private or internal deployments by setting
DISABLE_REGISTRATION="true"in your environment ordocker-compose.yaml. - Rotate
JWT_SECRETand all service credentials: Because an attacker who exploitedCVE-2026-115931orCVE-2026-115932could read/proc/self/environ(and an attacker who exploitedCVE-2026-115933could mint persistent session JWTs), generate a freshJWT_SECRETto invalidate all existing sessions and invitations, and rotateDATABASE_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 tagsv2.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”) addingSocialAbstract.localMediaPath()andUPLOAD_DIRECTORYcontainment. - 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”), tagsv2.25.3, and publishesCVE-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
- GAdvisory PSA-2026-JNXM56 (CVE-2026-115931: unrestricted provider method calls)
- GAdvisory PSA-2026-E9AHFV (CVE-2026-115932: path traversal in post media)
- GAdvisory PSA-2026-YT2HMH (CVE-2026-115933: forged signed JWT tokens)
- gitroomhq/postiz-app commit 9383f5be4d (CVE-2026-115931 fix in v2.25.2)
- gitroomhq/postiz-app commit 12db9bd2f4 (CVE-2026-115932 fix in v2.25.3)
- gitroomhq/postiz-app commit a5affe62c7 (CVE-2026-115933 fix in v2.25.3)
- gitroomhq/postiz-app release v2.25.3 (24 October 2026)
- NVD: CVE-2026-115931, CVE-2026-115932, and CVE-2026-115933