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
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": "3.2.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
79 changes: 67 additions & 12 deletions src/services/GitHub.ts
Original file line number Diff line number Diff line change
@@ -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}`)
}
}
}
Loading
Loading