VYPR
Unrated severityNVD Advisory· Published Jun 16, 2026

Postiz has an unauthenticated billing-enforcement bypass via /public/modify-subscription

CVE-2026-48783

Description

Postiz prior to v2.21.8 contains an unauthenticated endpoint that applies subscription-enforcement side effects to an attacker's own organization without verifying the token's intended purpose.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Postiz prior to v2.21.8 contains an unauthenticated endpoint that applies subscription-enforcement side effects to an attacker's own organization without verifying the token's intended purpose.

Vulnerability

Postiz versions prior to v2.21.8 contained an unauthenticated endpoint at /public/modify-subscription that accepted a signed token and applied subscription-enforcement side effects to the organization referenced in that token's claims, without verifying the token's intended purpose. The endpoint did not alter the persisted subscription tier, but it executed enforcement-related side effects on the caller's own organization, including adjusting team-member enablement state, disabling integrations exceeding the asserted plan's limits, and resetting the scheduled-post cron when the asserted plan was the free tier. The vulnerability exists only in instances where Stripe is configured and used. [1][2][4]

Exploitation

An attacker can call the unauthenticated endpoint /public/modify-subscription with a signed token whose claims reference their own organization. The endpoint applies subscription-enforcement side effects without any authentication check, requiring only network access to the vulnerable Postiz instance. The attack cannot be redirected at other tenants because the side effects are applied only to the organization identified in the token's claims, which the attacker controls. [2][4]

Impact

The attacker gains enforcement-related side effects for their own organization without providing payment. These include disabling team members, disabling integrations that exceed the asserted plan's limits, and resetting the scheduled-post cron when the free tier is asserted. The impact is limited to the attacker's own organization and cannot affect other tenant organizations through this endpoint. [2][4]

Mitigation

The vulnerability is fixed in Postiz version 2.21.8, released on 2026-05-22. No workaround is currently known other than upgrading to v2.21.8. [2][3]

AI Insight generated on Jun 17, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

1
23696d297351

feat: security fixes

https://github.com/gitroomhq/postiz-appNevo DavidMay 22, 2026via nvd-ref
10 files changed · +41 149
  • apps/backend/src/api/api.module.ts+0 2 modified
    @@ -24,7 +24,6 @@ import { PublicController } from '@gitroom/backend/api/routes/public.controller'
     import { RootController } from '@gitroom/backend/api/routes/root.controller';
     import { TrackService } from '@gitroom/nestjs-libraries/track/track.service';
     import { ShortLinkService } from '@gitroom/nestjs-libraries/short-linking/short.link.service';
    -import { Nowpayments } from '@gitroom/nestjs-libraries/crypto/nowpayments';
     import { WebhookController } from '@gitroom/backend/api/routes/webhooks.controller';
     import { SignatureController } from '@gitroom/backend/api/routes/signature.controller';
     import { AutopostController } from '@gitroom/backend/api/routes/autopost.controller';
    @@ -91,7 +90,6 @@ const authenticatedController = [
         IntegrationManager,
         TrackService,
         ShortLinkService,
    -    Nowpayments,
         AuthProviderManager,
         GithubProvider,
         GoogleProvider,
    
  • apps/backend/src/api/routes/billing.controller.ts+1 7 modified
    @@ -8,7 +8,6 @@ import { ApiTags } from '@nestjs/swagger';
     import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
     import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
     import { Request } from 'express';
    -import { Nowpayments } from '@gitroom/nestjs-libraries/crypto/nowpayments';
     import { AuthService } from '@gitroom/helpers/auth/auth.service';
     
     @ApiTags('Billing')
    @@ -17,8 +16,7 @@ export class BillingController {
       constructor(
         private _subscriptionService: SubscriptionService,
         private _stripeService: StripeService,
    -    private _notificationService: NotificationService,
    -    private _nowpayments: Nowpayments
    +    private _notificationService: NotificationService
       ) {}
     
       @Get('/check/:id')
    @@ -198,8 +196,4 @@ export class BillingController {
         );
       }
     
    -  @Get('/crypto')
    -  async crypto(@GetOrgFromRequest() org: Organization) {
    -    return this._nowpayments.createPaymentPage(org.id);
    -  }
     }
    
  • apps/backend/src/api/routes/no.auth.integrations.controller.ts+13 3 modified
    @@ -233,8 +233,8 @@ export class NoAuthIntegrationsController {
                   Buffer.from(body.code, 'base64').toString()
                 )
               : integrationProvider.isChromeExtension
    -          ? AuthService.signJWT(
    -              JSON.parse(Buffer.from(body.code, 'base64').toString())
    +          ? AuthService.fixedEncryption(
    +              Buffer.from(body.code, 'base64').toString()
                 )
               : undefined
           );
    @@ -297,8 +297,18 @@ export class NoAuthIntegrationsController {
             })
           : undefined;
     
    +    // Never leak stored credentials (signed/encrypted secrets) back to the
    +    // caller. These columns hold the integration access token, refresh token
    +    // and encrypted custom instance details and must stay server-side.
    +    const {
    +      token: _token,
    +      refreshToken: _refreshToken,
    +      customInstanceDetails: _customInstanceDetails,
    +      ...safeIntegration
    +    } = createUpdate as any;
    +
         return {
    -      ...createUpdate,
    +      ...safeIntegration,
           onboarding: onboarding === 'true',
           pages,
           ...(returnURL ? { returnURL } : {}),
    
  • apps/backend/src/api/routes/public.controller.ts+0 7 modified
    @@ -19,7 +19,6 @@ import { Request, Response } from 'express';
     import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
     import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
     import { AgentGraphInsertService } from '@gitroom/nestjs-libraries/agent/agent.graph.insert.service';
    -import { Nowpayments } from '@gitroom/nestjs-libraries/crypto/nowpayments';
     import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
     import { AuthService } from '@gitroom/helpers/auth/auth.service';
     import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
    @@ -38,7 +37,6 @@ export class PublicController {
         private _trackService: TrackService,
         private _agentGraphInsertService: AgentGraphInsertService,
         private _postsService: PostsService,
    -    private _nowpayments: Nowpayments,
         private _subscriptionService: SubscriptionService
       ) {}
       @Post('/agent')
    @@ -156,11 +154,6 @@ export class PublicController {
         }
       }
     
    -  @Post('/crypto/:path')
    -  async cryptoPost(@Body() body: any, @Param('path') path: string) {
    -    console.log('cryptoPost', body, path);
    -    return this._nowpayments.processPayment(path, body);
    -  }
     
       @Get('/stream')
       async streamFile(
    
  • apps/backend/src/services/auth/auth.middleware.ts+10 1 modified
    @@ -36,9 +36,18 @@ export class AuthMiddleware implements NestMiddleware {
           throw new HttpForbiddenException();
         }
         try {
    -      let user = AuthService.verifyJWT(auth) as User | null;
    +      // Verify the JWT signature only. Never trust authorization-relevant
    +      // claims (id, isSuperAdmin, activated) from the token body — always
    +      // re-resolve the user from the database using the id.
    +      const payload = AuthService.verifyJWT(auth) as User | null;
           const orgHeader = req.cookies.showorg || req.headers.showorg;
     
    +      if (!payload?.id) {
    +        throw new HttpForbiddenException();
    +      }
    +
    +      let user = (await this._userService.getUserById(payload.id)) as User | null;
    +
           if (!user) {
             throw new HttpForbiddenException();
           }
    
  • apps/frontend/src/components/billing/main.billing.component.tsx+0 2 modified
    @@ -23,7 +23,6 @@ import { useFireEvents } from '@gitroom/helpers/utils/use.fire.events';
     import { useUtmUrl } from '@gitroom/helpers/utils/utm.saver';
     import { useTrack } from '@gitroom/react/helpers/use.track';
     import { TrackEnum } from '@gitroom/nestjs-libraries/user/track.enum';
    -import { PurchaseCrypto } from '@gitroom/frontend/components/billing/purchase.crypto';
     import { useT } from '@gitroom/react/translation/get.transation.service.client';
     import { FinishTrial } from '@gitroom/frontend/components/billing/finish.trial';
     import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
    @@ -538,7 +537,6 @@ export const MainBillingComponent: FC<{
                 </div>
               ))}
           </div>
    -      {!subscription?.id && <PurchaseCrypto />}
           {!!subscription?.id && (
             <div className="flex justify-center mt-[20px] gap-[10px]">
               <Button onClick={updatePayment}>
    
  • apps/frontend/src/components/billing/purchase.crypto.tsx+0 30 removed
    @@ -1,30 +0,0 @@
    -import { FC, useCallback, useState } from 'react';
    -import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
    -import { Button } from '@gitroom/react/form/button';
    -import { useT } from '@gitroom/react/translation/get.transation.service.client';
    -export const PurchaseCrypto: FC = () => {
    -  const fetch = useFetch();
    -  const t = useT();
    -
    -  const [loading, setLoading] = useState(false);
    -  const load = useCallback(async () => {
    -    setLoading(true);
    -    const data = await (await fetch('/billing/crypto')).json();
    -    window.location.href = data.invoice_url;
    -  }, []);
    -  return (
    -    <div className="flex-1 bg-sixth items-center border border-customColor6 rounded-[4px] p-[24px] gap-[16px] flex [@media(max-width:1024px)]:items-center">
    -      <div>
    -        {t(
    -          'purchase_a_life_time_pro_account_with_sol_199',
    -          'Purchase a Life-time PRO account with SOL ($199)'
    -        )}
    -      </div>
    -      <div>
    -        <Button loading={loading} onClick={load}>
    -          {t('purchase_now', 'Purchase now')}
    -        </Button>
    -      </div>
    -    </div>
    -  );
    -};
    
  • libraries/nestjs-libraries/src/crypto/nowpayments.ts+0 78 removed
    @@ -1,78 +0,0 @@
    -import { Injectable } from '@nestjs/common';
    -import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
    -import { AuthService } from '@gitroom/helpers/auth/auth.service';
    -import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
    -
    -export interface ProcessPayment {
    -  payment_id: number;
    -  payment_status: string;
    -  pay_address: string;
    -  price_amount: number;
    -  price_currency: string;
    -  pay_amount: number;
    -  actually_paid: number;
    -  pay_currency: string;
    -  order_id: string;
    -  order_description: string;
    -  purchase_id: string;
    -  created_at: string;
    -  updated_at: string;
    -  outcome_amount: number;
    -  outcome_currency: string;
    -}
    -
    -@Injectable()
    -export class Nowpayments {
    -  constructor(private _subscriptionService: SubscriptionService) {}
    -
    -  async processPayment(path: string, body: ProcessPayment) {
    -    const decrypt = AuthService.verifyJWT(path) as any;
    -    if (!decrypt || !decrypt.order_id) {
    -      return;
    -    }
    -
    -    if (
    -      body.payment_status !== 'confirmed' &&
    -      body.payment_status !== 'finished'
    -    ) {
    -      return;
    -    }
    -
    -    const [org, make] = body.order_id.split('_');
    -    await this._subscriptionService.lifeTime(org, make, 'PRO');
    -    return body;
    -  }
    -
    -  async createPaymentPage(orgId: string) {
    -    const onlyId = makeId(5);
    -    const make = orgId + '_' + onlyId;
    -    const signRequest = AuthService.signJWT({ order_id: make });
    -
    -    const { id, invoice_url } = await (
    -      await fetch('https://api.nowpayments.io/v1/invoice', {
    -        method: 'POST',
    -        headers: {
    -          'Content-Type': 'application/json',
    -          'x-api-key': process.env.NOWPAYMENTS_API_KEY!,
    -        },
    -        body: JSON.stringify({
    -          price_amount: process.env.NOWPAYMENTS_AMOUNT,
    -          price_currency: 'USD',
    -          order_id: make,
    -          pay_currency: 'SOL',
    -          order_description: 'Lifetime deal account for Postiz',
    -          ipn_callback_url:
    -            process.env.NEXT_PUBLIC_BACKEND_URL +
    -            `/public/crypto/${signRequest}`,
    -          success_url: process.env.FRONTEND_URL + `/launches?check=${onlyId}`,
    -          cancel_url: process.env.FRONTEND_URL,
    -        }),
    -      })
    -    ).json();
    -
    -    return {
    -      id,
    -      invoice_url,
    -    };
    -  }
    -}
    
  • libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts+0 14 modified
    @@ -247,20 +247,6 @@ export class SubscriptionService {
         };
       }
     
    -  async lifeTime(orgId: string, identifier: string, subscription: any) {
    -    return this.createOrUpdateSubscription(
    -      false,
    -      identifier,
    -      identifier,
    -      pricing[subscription].channel!,
    -      subscription,
    -      'YEARLY',
    -      null,
    -      identifier,
    -      orgId
    -    );
    -  }
    -
       async addSubscription(orgId: string, userId: string, subscription: any) {
         await this._subscriptionRepository.setCustomerId(orgId, userId);
         return this.createOrUpdateSubscription(
    
  • libraries/nestjs-libraries/src/integrations/social/skool.provider.ts+17 5 modified
    @@ -31,10 +31,22 @@ export class SkoolProvider extends SocialAbstract implements SocialProvider {
         client_id: string;
         auth_token: string;
       } {
    -    return AuthService.verifyJWT(integration.customInstanceDetails!) as {
    -      client_id: string;
    -      auth_token: string;
    -    };
    +    const stored = integration.customInstanceDetails!;
    +    try {
    +      // Current format: credentials stored as at-rest AES-encrypted JSON.
    +      return JSON.parse(AuthService.fixedDecryption(stored)) as {
    +        client_id: string;
    +        auth_token: string;
    +      };
    +    } catch {
    +      // Legacy format: Skool accounts connected before the storage format was
    +      // changed kept their credentials as a signed JWT. Read those as-is so
    +      // already-connected accounts keep working without a reconnect.
    +      return AuthService.verifyJWT(stored) as {
    +        client_id: string;
    +        auth_token: string;
    +      };
    +    }
       }
     
       override handleErrors(
    @@ -106,7 +118,7 @@ export class SkoolProvider extends SocialAbstract implements SocialProvider {
           return {
             refreshToken: '',
             expiresIn: dayjs().add(100, 'year').unix() - dayjs().unix(),
    -        accessToken: AuthService.signJWT(cookies),
    +        accessToken: AuthService.fixedEncryption(JSON.stringify(cookies)),
             id: data.id,
             name: data.first_name + ' ' + data.last_name,
             picture: data.metadata.picture_profile || '',
    

Vulnerability mechanics

Root cause

"Missing verification of the JWT token's intended purpose in the public payment callback endpoint allowed an attacker to trigger subscription-enforcement side-effects on their own organization."

Attack vector

An unauthenticated attacker could call the public endpoint `POST /public/crypto/:path` with any validly-signed JWT token in the path parameter. The `processPayment` method verified only that the JWT was cryptographically signed and contained an `order_id` claim, but did not check that the token was issued for a legitimate payment callback or that the caller controlled the target organization. The attacker could craft or reuse a token referencing their own organization ID, and the endpoint would execute subscription-enforcement side-effects on that organization — including adjusting team-member enablement, disabling integrations exceeding the asserted plan's limits, and resetting the scheduled-post cron when the plan was the free tier. Impact is limited to the attacker's own organization and cannot be redirected at other tenants.

Affected code

The vulnerability resides in the `Nowpayments` service (`libraries/nestjs-libraries/src/crypto/nowpayments.ts`) and the public endpoint `POST /public/crypto/:path` in `public.controller.ts`. The `processPayment` method accepted a signed JWT token from the URL path and used its `order_id` claim to call `SubscriptionService.lifeTime()`, which applied subscription side-effects to the organization referenced in the token without verifying the token's intended purpose or that the caller owned that organization. The entire `Nowpayments` class and its associated route were removed in the patch [patch_id=6240377].

What the fix does

The patch removes the entire `Nowpayments` service class, the `POST /public/crypto/:path` route, the `GET /billing/crypto` route, the `lifeTime()` method from `SubscriptionService`, and the frontend `PurchaseCrypto` component. By deleting the vulnerable code entirely, the unauthenticated endpoint that accepted arbitrary signed tokens and applied subscription side-effects is eliminated. Additionally, the patch hardens the auth middleware to re-resolve the user from the database rather than trusting JWT claims, and stops leaking stored credentials in integration API responses [patch_id=6240377].

Preconditions

  • inputThe attacker must have a validly-signed JWT token containing an `order_id` claim that references an organization they control.
  • networkThe endpoint is unauthenticated and publicly accessible, requiring no prior authentication or authorization.

Generated on Jun 17, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.