VYPR
Critical severity10.0NVD Advisory· Published Jul 9, 2025· Updated Apr 15, 2026

CVE-2025-53624

CVE-2025-53624

Description

The Docusaurus gists plugin adds a page to your Docusaurus instance, displaying all public gists of a GitHub user. docusaurus-plugin-content-gists versions prior to 4.0.0 are vulnerable to exposing GitHub Personal Access Tokens in production build artifacts when passed through plugin configuration options. The token, intended for build-time API access only, is inadvertently included in client-side JavaScript bundles, making it accessible to anyone who can view the website's source code. This vulnerability is fixed in 4.0.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
docusaurus-plugin-content-gistsnpm
< 4.0.04.0.0

Patches

1
8d4230b82412

fix: critical security issue, where PAT was exposed (#7)

10 files changed · +390 80
  • package.json+2 1 modified
    @@ -1,6 +1,6 @@
     {
       "name": "docusaurus-plugin-content-gists",
    -  "version": "3.1.0",
    +  "version": "4.0.0",
       "description": "Display gists from GitHub as content in Docusaurus",
       "keywords": [
         "docusaurus",
    @@ -35,6 +35,7 @@
         "@docusaurus/theme-translations": "^3.1.1",
         "@docusaurus/types": "^3.1.1",
         "@docusaurus/utils-validation": "^3.1.1",
    +    "@octokit/plugin-throttling": "^11.0.1",
         "octokit": "^3.1.2"
       },
       "devDependencies": {
    
  • README.md+26 9 modified
    @@ -10,6 +10,16 @@ The user is automatically configured based on the (GitHub PAT) token provided.
     
     See it in action on [Takken.io](https://takken.io).
     
    +## ⚠️ Security Update (v4.0.0+)
    +
    +**Breaking Change:** For security reasons, the `personalAccessToken` option has been removed. The
    +GitHub token must now be provided via the `GH_PERSONAL_ACCESS_TOKEN` environment variable only.
    +
    +If you're upgrading from a previous version:
    +
    +1. Remove `personalAccessToken` from your plugin configuration
    +2. Ensure `GH_PERSONAL_ACCESS_TOKEN` is set in your environment
    +
     ## Setup
     
     ### Install dependencies
    @@ -35,7 +45,7 @@ yarn add dotenv docusaurus-plugin-content-gists
     #### `.env`
     
     ```env
    -GITHUB_PERSONAL_ACCESS_TOKEN=ghp_your_token_here
    +GH_PERSONAL_ACCESS_TOKEN=ghp_your_token_here
     ```
     
     #### `docusaurus.config.js`
    @@ -52,7 +62,6 @@ const config = {
           {
             enabled: true,
             verbose: true,
    -        personalAccessToken: process.env.GITHUB_PERSONAL_ACCESS_TOKEN,
           },
         ],
       ],
    @@ -66,17 +75,25 @@ const config = {
     }
     ```
     
    -### Options
    +### Authentication
    +
    +The plugin requires a GitHub Personal Access Token to fetch gists. For security reasons, this token
    +must be provided via the `GH_PERSONAL_ACCESS_TOKEN` environment variable.
     
    -#### `personalAccessToken`
    +#### Creating a GitHub Personal Access Token
     
    -Personal access token of the user of whom to get the gists.
    +1. Go to GitHub Settings → Developer settings → Personal access tokens →
    +   [Tokens (classic)](https://github.com/settings/tokens)
    +2. Click "Generate new token" → "Generate new token (classic)"
    +3. Give it a descriptive name (e.g., "Docusaurus Gists Plugin")
    +4. Select the `gist` scope (read access to gists)
    +5. Click "Generate token" and copy the token
     
    -> **Important:** We recommend you use an environment variable like `GITHUB_PERSONAL_ACCESS_TOKEN`.
    ->
    -> That way you do not risk accidentally exposing access to your GitHub account.
    +> **Security Notice:** Never pass the token directly through plugin options. Always use environment
    +> variables to prevent accidental exposure of your GitHub credentials in your codebase or build
    +> artifacts.
     
    -_**required:** `true`_
    +### Options
     
     #### `enabled`
     
    
  • src/client/GistsContext.tsx+38 0 added
    @@ -0,0 +1,38 @@
    +import React, { createContext, useContext, ReactNode } from 'react'
    +import { GistsClient, RuntimeConfig } from './index'
    +
    +interface GistsContextType {
    +  client: GistsClient
    +  config: RuntimeConfig
    +}
    +
    +const GistsContext = createContext<GistsContextType | undefined>(undefined)
    +
    +interface GistsProviderProps {
    +  children: ReactNode
    +  config: RuntimeConfig
    +}
    +
    +export function GistsProvider({ children, config }: GistsProviderProps) {
    +  const client = new GistsClient(config)
    +
    +  return <GistsContext.Provider value={{ client, config }}>{children}</GistsContext.Provider>
    +}
    +
    +export function useGists() {
    +  const context = useContext(GistsContext)
    +  if (context === undefined) {
    +    throw new Error('useGists must be used within a GistsProvider')
    +  }
    +  return context
    +}
    +
    +export function useGistsClient() {
    +  const { client } = useGists()
    +  return client
    +}
    +
    +export function useGistsConfig() {
    +  const { config } = useGists()
    +  return config
    +}
    
  • src/client/index.ts+77 0 added
    @@ -0,0 +1,77 @@
    +/**
    + * Client-side plugin component
    + * This runs in the browser and has access to runtime configuration only
    + */
    +
    +interface RuntimeConfig {
    +  enabled: boolean
    +  verbose: boolean
    +  gistListPageComponent: string
    +  gistPageComponent: string
    +}
    +
    +class GistsClient {
    +  private config: RuntimeConfig
    +
    +  constructor(config: RuntimeConfig) {
    +    this.config = config
    +  }
    +
    +  // Client-side utility methods
    +  isEnabled(): boolean {
    +    return this.config.enabled
    +  }
    +
    +  isVerbose(): boolean {
    +    return this.config.verbose
    +  }
    +
    +  getGistListComponent(): string {
    +    return this.config.gistListPageComponent
    +  }
    +
    +  getGistPageComponent(): string {
    +    return this.config.gistPageComponent
    +  }
    +
    +  // Client-side analytics or tracking
    +  trackGistView(gistId: string): void {
    +    if (this.config.verbose) {
    +      console.log(`Viewing gist: ${gistId}`)
    +    }
    +
    +    // Could send analytics events here
    +    // Note: No access to GitHub API tokens - this is client-side only
    +  }
    +
    +  // Client-side URL helpers
    +  getGistUrl(gistId: string): string {
    +    return `/gists/${gistId}`
    +  }
    +
    +  getGistListUrl(): string {
    +    return '/gists'
    +  }
    +
    +  // Client-side search/filtering (if needed)
    +  filterGists(gists: any[], searchTerm: string): any[] {
    +    if (!searchTerm) return gists
    +
    +    return gists.filter(
    +      (gist) =>
    +        gist.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
    +        Object.values(gist.files || {}).some((file: any) =>
    +          file.filename?.toLowerCase().includes(searchTerm.toLowerCase()),
    +        ),
    +    )
    +  }
    +}
    +
    +// Export factory function
    +export function createGistsClient(config: RuntimeConfig): GistsClient {
    +  return new GistsClient(config)
    +}
    +
    +// Export types for theme components
    +export type { RuntimeConfig }
    +export { GistsClient }
    
  • src/index.ts+69 19 modified
    @@ -9,20 +9,57 @@ type Content = {
     interface Options extends PluginOptions {
       enabled: boolean
       verbose: boolean
    -  personalAccessToken: string
    +}
    +
    +// Runtime configuration (safe to send to client)
    +interface RuntimeOptions {
    +  enabled: boolean
    +  verbose: boolean
       gistListPageComponent: string
       gistPageComponent: string
     }
     
    +const defaults = {
    +  enabled: true,
    +  verbose: false,
    +  gistPageComponent: '@theme/GistPage',
    +  gistListPageComponent: '@theme/GistListPage',
    +}
    +
     export default async function gists(context: LoadContext, options: Options): Promise<Plugin> {
    -  const { enabled, verbose, personalAccessToken, gistListPageComponent, gistPageComponent } =
    -    options
    +  const { enabled, verbose } = options
    +
    +  // Get token from environment during build time only
    +  const personalAccessToken = process.env.GH_PERSONAL_ACCESS_TOKEN
     
       // Disabled
       if (!enabled) return { name: 'docusaurus-plugin-content-gists' }
     
    +  // Validate token exists and is not empty
    +  if (!personalAccessToken || personalAccessToken.trim() === '') {
    +    throw new Error('GitHub Personal Access Token is required but not provided')
    +  }
    +
    +  // Mask token for logging purposes
    +  const maskedToken =
    +    personalAccessToken.substring(0, 4) +
    +    '...' +
    +    personalAccessToken.substring(personalAccessToken.length - 4)
    +  if (verbose) console.log(`Using GitHub token: ${maskedToken}`)
    +
       const api = new GitHub({ personalAccessToken })
     
    +  // Runtime options (safe to send to client) - exclude sensitive data
    +  const runtimeOptions: RuntimeOptions = {
    +    enabled,
    +    verbose,
    +    gistListPageComponent: defaults.gistListPageComponent,
    +    gistPageComponent: defaults.gistPageComponent,
    +  }
    +
    +  // Note: personalAccessToken is intentionally excluded from runtimeOptions
    +  // to prevent it from being bundled in client code
    +
       return {
         name: 'docusaurus-plugin-content-gists',
     
    @@ -34,18 +71,20 @@ export default async function gists(context: LoadContext, options: Options): Pro
           return '../src/theme'
         },
     
    +    // Build-time data fetching (server-side only)
         async loadContent(): Promise<Content> {
           if (verbose) console.log('--- Gists ---')
     
           const user = await api.getUsername()
    -      if (verbose) console.log(`Retrieving ${user}'s public gists.`)
    +      if (verbose) console.log(`Retrieving public gists.`)
     
           const gists = await api.getMyGists()
    -      console.log(`Found ${gists.length} public gists for ${user}.`)
    +      if (verbose) console.log(`Found ${gists.length} public gists.`)
     
           return { gists }
         },
     
    +    // Build-time route generation
         async contentLoaded({ content, actions }) {
           const { gists } = content as { gists: Gists }
     
    @@ -54,28 +93,39 @@ export default async function gists(context: LoadContext, options: Options): Pro
     
           actions.addRoute({
             path: `/gists`,
    -        component: gistListPageComponent,
    +        component: runtimeOptions.gistListPageComponent,
             modules: {
               gists: gistsData,
             },
             exact: true,
           })
     
           // Pages
    -      for (const gistMeta of gists) {
    -        const id = gistMeta.id
    -
    -        const gist = await actions.createData(
    -          `gist-${id}.json`,
    -          JSON.stringify(await api.getGist(id)),
    +      const maxConcurrent = 5 // Process gists in batches to avoid overwhelming the API
    +      for (let i = 0; i < gists.length; i += maxConcurrent) {
    +        const batch = gists.slice(i, i + maxConcurrent)
    +
    +        await Promise.all(
    +          batch.map(async (gistMeta) => {
    +            const id = gistMeta.id
    +
    +            try {
    +              const gistData = await api.getGist(id)
    +              const gist = await actions.createData(`gist-${id}.json`, JSON.stringify(gistData))
    +
    +              actions.addRoute({
    +                path: `/gists/${id}`,
    +                component: runtimeOptions.gistPageComponent,
    +                modules: { gist },
    +                exact: true,
    +              })
    +            } catch (error) {
    +              const message = error instanceof Error ? error.message : 'Unknown error'
    +              console.error(`Failed to process gist ${id}: ${message}`)
    +              // Continue processing other gists even if one fails
    +            }
    +          }),
             )
    -
    -        actions.addRoute({
    -          path: `/gists/${id}`,
    -          component: gistPageComponent,
    -          modules: { gist },
    -          exact: true,
    -        })
           }
         },
       }
    
  • src/services/GitHub.ts+67 12 modified
    @@ -1,39 +1,94 @@
     import { Octokit } from 'octokit'
    +import { throttling } from '@octokit/plugin-throttling'
     import { Authenticated, Gist, Gists } from '../types'
     
     type Props = {
       personalAccessToken: string
     }
     
    +const OctokitWithThrottling = Octokit.plugin(throttling)
    +
     export default class GitHub {
    -  private instance: InstanceType<typeof Octokit>
    +  private instance: InstanceType<typeof OctokitWithThrottling>
    +  private maxGists: number = 100 // Limit to prevent resource exhaustion
     
       constructor(props: Props) {
         const { personalAccessToken: auth } = props
    -    this.instance = new Octokit({ auth })
    +    this.instance = new OctokitWithThrottling({
    +      auth,
    +      throttle: {
    +        onRateLimit: (retryAfter: number, options: any) => {
    +          console.warn(`Request quota exhausted for request ${options.method} ${options.url}`)
    +          if (options.request.retryCount === 0) {
    +            console.log(`Retrying after ${retryAfter} seconds!`)
    +            return true
    +          }
    +          return false
    +        },
    +        onSecondaryRateLimit: (retryAfter: number, options: any) => {
    +          console.warn(`Secondary rate limit detected for request ${options.method} ${options.url}`)
    +          return false
    +        },
    +      },
    +    })
       }
     
       public async getAuthenticated(): Promise<Authenticated> {
    -    const response = await this.instance.rest.users.getAuthenticated()
    -
    -    return response.data
    +    try {
    +      const response = await this.instance.rest.users.getAuthenticated()
    +      return response.data
    +    } catch (error) {
    +      const message = error instanceof Error ? error.message : 'Unknown error'
    +      console.error('Failed to authenticate with GitHub:', message)
    +      throw new Error('GitHub authentication failed. Please check your Personal Access Token.')
    +    }
       }
     
       public async getUsername() {
    -    const authenticated = await this.getAuthenticated()
    -
    -    return authenticated?.login || null
    +    try {
    +      const authenticated = await this.getAuthenticated()
    +      return authenticated?.login || null
    +    } catch (error) {
    +      const message = error instanceof Error ? error.message : 'Unknown error'
    +      console.error('Failed to get username:', message)
    +      return null
    +    }
       }
     
       public async getMyGists(): Promise<Gists> {
    -    const response = await this.instance.rest.gists.list()
    +    try {
    +      const response = await this.instance.rest.gists.list({
    +        per_page: this.maxGists,
    +        page: 1,
    +      })
    +
    +      const publicGists = response.data.filter((gist) => gist.public === true)
    +
    +      if (publicGists.length === this.maxGists) {
    +        console.warn(`Gist limit of ${this.maxGists} reached. Some gists may not be included.`)
    +      }
     
    -    return response.data.filter((gist) => gist.public === true)
    +      return publicGists.slice(0, this.maxGists)
    +    } catch (error) {
    +      const message = error instanceof Error ? error.message : 'Unknown error'
    +      console.error('Failed to fetch gists:', message)
    +      return []
    +    }
       }
     
       public async getGist(id: string): Promise<Gist> {
    -    const response = await this.instance.rest.gists.get({ gist_id: id })
    +    // Validate gist ID format
    +    if (!id || !/^[a-f0-9]{32}$/.test(id)) {
    +      throw new Error(`Invalid gist ID format: ${id}`)
    +    }
     
    -    return response.data
    +    try {
    +      const response = await this.instance.rest.gists.get({ gist_id: id })
    +      return response.data
    +    } catch (error) {
    +      const message = error instanceof Error ? error.message : 'Unknown error'
    +      console.error(`Failed to fetch gist ${id}:`, message)
    +      throw new Error(`Failed to fetch gist: ${message}`)
    +    }
       }
     }
    
  • src/theme/GistListPage/index.tsx+60 29 modified
    @@ -1,43 +1,74 @@
     // @ts-ignore
    -import React from 'react'
    +import React, { useEffect } from 'react'
     import type { Gists } from '../../types'
     // @ts-ignore
     import GistLayout from '@theme/GistLayout'
     // @ts-ignore
     import styles from './styles.module.css'
    +import { createGistsClient } from '../../client/index'
     
     interface Props {
       gists: Gists
     }
     
    -const GistListPage = ({ gists }: Props) => (
    -  <GistLayout>
    -    {(gists.length >= 1 && (
    -      <ul className={styles.list}>
    -        <h1>Gists</h1>
    -        {gists.map(({ id, created_at, updated_at, description, files }) => {
    -          const title = Object.values(files)[0]!.filename
    -          const createdDate = new Date(created_at).toDateString()
    -          const updatedDate = new Date(updated_at).toDateString()
    +// Sanitize user-generated content to prevent XSS
    +const sanitizeText = (text: string | null | undefined): string => {
    +  if (!text) return ''
    +  // Basic HTML entity encoding for safety
    +  return text
    +    .replace(/&/g, '&amp;')
    +    .replace(/</g, '&lt;')
    +    .replace(/>/g, '&gt;')
    +    .replace(/"/g, '&quot;')
    +    .replace(/'/g, '&#039;')
    +}
    +
    +const GistListPage = ({ gists }: Props) => {
    +  // Get runtime config from environment (set by webpack)
    +  const runtimeConfig = typeof window !== 'undefined' && (window as any).GISTS_CONFIG
    +  const client = runtimeConfig ? createGistsClient(runtimeConfig) : null
    +
    +  useEffect(() => {
    +    // Client-side analytics or tracking
    +    if (client && client.isVerbose()) {
    +      console.log('Gist list page loaded')
    +    }
    +  }, [client])
     
    -          return (
    -            <div key={id}>
    -              <li className={styles.item}>
    -                <div className={styles.dates}>
    -                  <sup>Created on {createdDate}</sup>
    -                  <sup>Last updated on {updatedDate}</sup>
    -                </div>
    -                <a className={styles.title} href={`/gists/${id}`}>
    -                  <h1>{title}</h1>
    -                </a>
    -                <summary className={styles.description}>{description}</summary>
    -              </li>
    -            </div>
    -          )
    -        })}
    -      </ul>
    -    )) || <div className={styles.empty}>No gists exist yet</div>}
    -  </GistLayout>
    -)
    +  return (
    +    <GistLayout>
    +      {(gists.length >= 1 && (
    +        <ul className={styles.list}>
    +          <h1>Gists</h1>
    +          {gists.map(({ id, created_at, updated_at, description, files }) => {
    +            const file = Object.values(files)[0]
    +            const title = file ? sanitizeText(file.filename) : 'Untitled'
    +            const createdDate = new Date(created_at).toDateString()
    +            const updatedDate = new Date(updated_at).toDateString()
    +
    +            return (
    +              <div key={id}>
    +                <li className={styles.item}>
    +                  <div className={styles.dates}>
    +                    <sup>Created on {createdDate}</sup>
    +                    <sup>Last updated on {updatedDate}</sup>
    +                  </div>
    +                  <a
    +                    className={styles.title}
    +                    href={client ? client.getGistUrl(id) : `/gists/${id}`}
    +                    onClick={() => client?.trackGistView(id)}
    +                  >
    +                    <h1>{title}</h1>
    +                  </a>
    +                  <summary className={styles.description}>{sanitizeText(description)}</summary>
    +                </li>
    +              </div>
    +            )
    +          })}
    +        </ul>
    +      )) || <div className={styles.empty}>No gists exist yet</div>}
    +    </GistLayout>
    +  )
    +}
     
     export default GistListPage
    
  • src/validateOptions.ts+21 9 modified
    @@ -9,15 +9,8 @@ const defaults = {
     }
     
     export const Schema = Joi.object({
    -  enabled: Joi.string()
    -    .equal(true, false)
    -    .default(defaults.enabled)
    -    .label('Whether the plugin is enabled or not.'),
    -  verbose: Joi.string()
    -    .equal(true, false)
    -    .default(defaults.verbose)
    -    .label('Verbose output during build phase'),
    -  personalAccessToken: Joi.string().required().label('GitHub Personal Access Token'),
    +  enabled: Joi.boolean().default(defaults.enabled).label('Whether the plugin is enabled or not.'),
    +  verbose: Joi.boolean().default(defaults.verbose).label('Verbose output during build phase'),
       gistPageComponent: Joi.string()
         .default(defaults.gistPageComponent)
         .label('The component for the page that shows the gist'),
    @@ -30,5 +23,24 @@ export function validateOptions({
       validate,
       options,
     }: OptionValidationContext<ValidationSchema<PluginOptions>, PluginOptions>) {
    +  // If there's a legacy configuration that still has `personalAccessToken` configured,
    +  // we should throw a descriptive error with instructions to use `GH_PERSONAL_ACCESS_TOKEN` and remove the legacy option.
    +  if ('personalAccessToken' in options && options.personalAccessToken) {
    +    const message =
    +      '\n---\n\n' +
    +      'Because of a critical security issue, `personalAccessToken` is no longer passed through the plugin options.\n' +
    +      "Please set the `GH_PERSONAL_ACCESS_TOKEN` environment variable instead (if you haven't already)." +
    +      '\n\n---'
    +
    +    console.warn(message)
    +
    +    throw new Error(message)
    +  }
    +
    +  // If no token in environment, throw a clear error
    +  if (!process.env.GH_PERSONAL_ACCESS_TOKEN) {
    +    throw new Error('Please set GH_PERSONAL_ACCESS_TOKEN environment variable.')
    +  }
    +
       return validate(Schema, options)
     }
    
  • tsconfig.theme.json+1 1 modified
    @@ -10,6 +10,6 @@
         "module": "esnext",
         "target": "esnext"
       },
    -  "include": ["src/theme", "src/core", "src/*.d.ts"],
    +  "include": ["src/theme", "src/core", "src/client", "src/*.d.ts"],
       "exclude": ["**/__tests__/**"]
     }
    
  • yarn.lock+29 0 modified
    @@ -2375,6 +2375,13 @@ __metadata:
       languageName: node
       linkType: hard
     
    +"@octokit/openapi-types@npm:^25.1.0":
    +  version: 25.1.0
    +  resolution: "@octokit/openapi-types@npm:25.1.0"
    +  checksum: 10/91989a4cec12250e6b3226e9aa931c05c27d46a946725d01e6a831af3890f157210a7032f07641a156c608cc6bf6cf55a28f07179910b644966358d6d559dec6
    +  languageName: node
    +  linkType: hard
    +
     "@octokit/plugin-paginate-graphql@npm:^4.0.0":
       version: 4.0.1
       resolution: "@octokit/plugin-paginate-graphql@npm:4.0.1"
    @@ -2419,6 +2426,18 @@ __metadata:
       languageName: node
       linkType: hard
     
    +"@octokit/plugin-throttling@npm:^11.0.1":
    +  version: 11.0.1
    +  resolution: "@octokit/plugin-throttling@npm:11.0.1"
    +  dependencies:
    +    "@octokit/types": "npm:^14.0.0"
    +    bottleneck: "npm:^2.15.3"
    +  peerDependencies:
    +    "@octokit/core": ^7.0.0
    +  checksum: 10/814e2051e8f706a2449f22c6e5dd28d30ed792445f15506487829eb9c68fbe02fb0518da14140e4e5dfea0197cb058c0eb0e6f49c9ad763dea8a5c5383877d2c
    +  languageName: node
    +  linkType: hard
    +
     "@octokit/plugin-throttling@npm:^8.0.0":
       version: 8.2.0
       resolution: "@octokit/plugin-throttling@npm:8.2.0"
    @@ -2463,6 +2482,15 @@ __metadata:
       languageName: node
       linkType: hard
     
    +"@octokit/types@npm:^14.0.0":
    +  version: 14.1.0
    +  resolution: "@octokit/types@npm:14.1.0"
    +  dependencies:
    +    "@octokit/openapi-types": "npm:^25.1.0"
    +  checksum: 10/ea5549ca6176bd1184427141a77bca88c68f07d252d3ea1db7f9b58ec16b66391218a75a99927efb1e36a2cb00e8ed37a79b71fdc95a1117a9982516156fd997
    +  languageName: node
    +  linkType: hard
    +
     "@octokit/webhooks-methods@npm:^4.1.0":
       version: 4.1.0
       resolution: "@octokit/webhooks-methods@npm:4.1.0"
    @@ -5196,6 +5224,7 @@ __metadata:
         "@docusaurus/tsconfig": "npm:^3.1.1"
         "@docusaurus/types": "npm:^3.1.1"
         "@docusaurus/utils-validation": "npm:^3.1.1"
    +    "@octokit/plugin-throttling": "npm:^11.0.1"
         "@octokit/types": "npm:^12.6.0"
         "@types/node": "npm:^20.11.30"
         "@types/react": "npm:^18.2.67"
    

Vulnerability mechanics

Generated by null/stub on May 9, 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.