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.
| Package | Affected versions | Patched versions |
|---|---|---|
docusaurus-plugin-content-gistsnpm | < 4.0.0 | 4.0.0 |
Patches
18d4230b82412fix: 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, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +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- github.com/advisories/GHSA-qf34-qpr4-5pphghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-53624ghsaADVISORY
- github.com/webbertakken/docusaurus-plugin-content-gists/commit/8d4230b82412edb215ddfa9e609d178510a5fe31nvdWEB
- github.com/webbertakken/docusaurus-plugin-content-gists/security/advisories/GHSA-qf34-qpr4-5pphnvdWEB
News mentions
0No linked articles in our index yet.