Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 9, 2025
Merged
29 changes: 20 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ 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
Expand All @@ -35,7 +43,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`
Expand All @@ -52,7 +60,6 @@ const config = {
{
enabled: true,
verbose: true,
personalAccessToken: process.env.GITHUB_PERSONAL_ACCESS_TOKEN,
},
],
],
Expand All @@ -66,17 +73,21 @@ 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`

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
42 changes: 42 additions & 0 deletions src/client/GistsContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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
}
76 changes: 76 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* 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())
)
)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace any[] with proper type definition

Using any[] defeats TypeScript's type safety. Import and use the proper Gist type from your types module.

-  filterGists(gists: any[], searchTerm: string): any[] {
+  filterGists(gists: Gist[], searchTerm: string): Gist[] {

You'll need to import the Gist type at the top of the file:

import type { Gist } from '../types'
🤖 Prompt for AI Agents
In src/client/index.ts around lines 57 to 66, the filterGists function uses the
type any[] for the gists parameter and return type, which bypasses TypeScript's
type safety. Import the proper Gist type from the types module at the top of the
file with "import type { Gist } from '../types'". Then update the function
signature to use Gist[] instead of any[] for both the parameter and return type
to ensure correct typing.

}

// Export factory function
export function createGistsClient(config: RuntimeConfig): GistsClient {
return new GistsClient(config)
}

// Export types for theme components
export type { RuntimeConfig }
export { GistsClient }
89 changes: 70 additions & 19 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand All @@ -34,18 +71,21 @@ 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 }

Expand All @@ -54,28 +94,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,
})
}
},
}
Expand Down
Loading
Loading