Skip to content

Commit 8d4230b

Browse files
authored
fix: critical security issue, where PAT was exposed (#7)
* fix: critical security vulnerabilities - Prevent token exposure by masking in console logs - Add proper token format validation (ghp_* or github_pat_*) - Implement rate limiting with @octokit/plugin-throttling - Add resource limits (max 100 gists) to prevent DoS - Fix input validation for boolean options - Add XSS protection with HTML entity encoding - Improve error handling with graceful degradation - Add concurrent request limiting (max 5) - Remove username disclosure from logs These changes significantly improve the security posture of the plugin by addressing token exposure, resource exhaustion, and input validation vulnerabilities. * fix: security issue * feat: implement split architecture for security - Token is now read from environment during build time only - Runtime configuration excludes sensitive data - All API calls happen during build time, creating static JSON files - Prevents token exposure in client-side code * feat: accept personalAccessToken from options for cleaner API * feat: use webpack DefinePlugin to exclude sensitive data from client builds * revert: use environment variable approach for security * chore: fail if env variable is not provided * feat: add proper description for people that upgrade * docs: update README for v4.0.0 security changes - Add security update notice for breaking change - Update configuration examples to remove personalAccessToken - Add instructions for creating GitHub PAT - Bump version to 4.0.0 for breaking change * chore: formatting
1 parent d6d3c8b commit 8d4230b

File tree

10 files changed

+390
-80
lines changed

10 files changed

+390
-80
lines changed

README.md

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ The user is automatically configured based on the (GitHub PAT) token provided.
1010

1111
See it in action on [Takken.io](https://takken.io).
1212

13+
## ⚠️ Security Update (v4.0.0+)
14+
15+
**Breaking Change:** For security reasons, the `personalAccessToken` option has been removed. The
16+
GitHub token must now be provided via the `GH_PERSONAL_ACCESS_TOKEN` environment variable only.
17+
18+
If you're upgrading from a previous version:
19+
20+
1. Remove `personalAccessToken` from your plugin configuration
21+
2. Ensure `GH_PERSONAL_ACCESS_TOKEN` is set in your environment
22+
1323
## Setup
1424

1525
### Install dependencies
@@ -35,7 +45,7 @@ yarn add dotenv docusaurus-plugin-content-gists
3545
#### `.env`
3646

3747
```env
38-
GITHUB_PERSONAL_ACCESS_TOKEN=ghp_your_token_here
48+
GH_PERSONAL_ACCESS_TOKEN=ghp_your_token_here
3949
```
4050

4151
#### `docusaurus.config.js`
@@ -52,7 +62,6 @@ const config = {
5262
{
5363
enabled: true,
5464
verbose: true,
55-
personalAccessToken: process.env.GITHUB_PERSONAL_ACCESS_TOKEN,
5665
},
5766
],
5867
],
@@ -66,17 +75,25 @@ const config = {
6675
}
6776
```
6877

69-
### Options
78+
### Authentication
79+
80+
The plugin requires a GitHub Personal Access Token to fetch gists. For security reasons, this token
81+
must be provided via the `GH_PERSONAL_ACCESS_TOKEN` environment variable.
7082

71-
#### `personalAccessToken`
83+
#### Creating a GitHub Personal Access Token
7284

73-
Personal access token of the user of whom to get the gists.
85+
1. Go to GitHub Settings → Developer settings → Personal access tokens →
86+
[Tokens (classic)](https://github.com/settings/tokens)
87+
2. Click "Generate new token" → "Generate new token (classic)"
88+
3. Give it a descriptive name (e.g., "Docusaurus Gists Plugin")
89+
4. Select the `gist` scope (read access to gists)
90+
5. Click "Generate token" and copy the token
7491

75-
> **Important:** We recommend you use an environment variable like `GITHUB_PERSONAL_ACCESS_TOKEN`.
76-
>
77-
> That way you do not risk accidentally exposing access to your GitHub account.
92+
> **Security Notice:** Never pass the token directly through plugin options. Always use environment
93+
> variables to prevent accidental exposure of your GitHub credentials in your codebase or build
94+
> artifacts.
7895
79-
_**required:** `true`_
96+
### Options
8097

8198
#### `enabled`
8299

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "docusaurus-plugin-content-gists",
3-
"version": "3.1.0",
3+
"version": "4.0.0",
44
"description": "Display gists from GitHub as content in Docusaurus",
55
"keywords": [
66
"docusaurus",
@@ -35,6 +35,7 @@
3535
"@docusaurus/theme-translations": "^3.1.1",
3636
"@docusaurus/types": "^3.1.1",
3737
"@docusaurus/utils-validation": "^3.1.1",
38+
"@octokit/plugin-throttling": "^11.0.1",
3839
"octokit": "^3.1.2"
3940
},
4041
"devDependencies": {

src/client/GistsContext.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import React, { createContext, useContext, ReactNode } from 'react'
2+
import { GistsClient, RuntimeConfig } from './index'
3+
4+
interface GistsContextType {
5+
client: GistsClient
6+
config: RuntimeConfig
7+
}
8+
9+
const GistsContext = createContext<GistsContextType | undefined>(undefined)
10+
11+
interface GistsProviderProps {
12+
children: ReactNode
13+
config: RuntimeConfig
14+
}
15+
16+
export function GistsProvider({ children, config }: GistsProviderProps) {
17+
const client = new GistsClient(config)
18+
19+
return <GistsContext.Provider value={{ client, config }}>{children}</GistsContext.Provider>
20+
}
21+
22+
export function useGists() {
23+
const context = useContext(GistsContext)
24+
if (context === undefined) {
25+
throw new Error('useGists must be used within a GistsProvider')
26+
}
27+
return context
28+
}
29+
30+
export function useGistsClient() {
31+
const { client } = useGists()
32+
return client
33+
}
34+
35+
export function useGistsConfig() {
36+
const { config } = useGists()
37+
return config
38+
}

src/client/index.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Client-side plugin component
3+
* This runs in the browser and has access to runtime configuration only
4+
*/
5+
6+
interface RuntimeConfig {
7+
enabled: boolean
8+
verbose: boolean
9+
gistListPageComponent: string
10+
gistPageComponent: string
11+
}
12+
13+
class GistsClient {
14+
private config: RuntimeConfig
15+
16+
constructor(config: RuntimeConfig) {
17+
this.config = config
18+
}
19+
20+
// Client-side utility methods
21+
isEnabled(): boolean {
22+
return this.config.enabled
23+
}
24+
25+
isVerbose(): boolean {
26+
return this.config.verbose
27+
}
28+
29+
getGistListComponent(): string {
30+
return this.config.gistListPageComponent
31+
}
32+
33+
getGistPageComponent(): string {
34+
return this.config.gistPageComponent
35+
}
36+
37+
// Client-side analytics or tracking
38+
trackGistView(gistId: string): void {
39+
if (this.config.verbose) {
40+
console.log(`Viewing gist: ${gistId}`)
41+
}
42+
43+
// Could send analytics events here
44+
// Note: No access to GitHub API tokens - this is client-side only
45+
}
46+
47+
// Client-side URL helpers
48+
getGistUrl(gistId: string): string {
49+
return `/gists/${gistId}`
50+
}
51+
52+
getGistListUrl(): string {
53+
return '/gists'
54+
}
55+
56+
// Client-side search/filtering (if needed)
57+
filterGists(gists: any[], searchTerm: string): any[] {
58+
if (!searchTerm) return gists
59+
60+
return gists.filter(
61+
(gist) =>
62+
gist.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
63+
Object.values(gist.files || {}).some((file: any) =>
64+
file.filename?.toLowerCase().includes(searchTerm.toLowerCase()),
65+
),
66+
)
67+
}
68+
}
69+
70+
// Export factory function
71+
export function createGistsClient(config: RuntimeConfig): GistsClient {
72+
return new GistsClient(config)
73+
}
74+
75+
// Export types for theme components
76+
export type { RuntimeConfig }
77+
export { GistsClient }

src/index.ts

Lines changed: 69 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,57 @@ type Content = {
99
interface Options extends PluginOptions {
1010
enabled: boolean
1111
verbose: boolean
12-
personalAccessToken: string
12+
}
13+
14+
// Runtime configuration (safe to send to client)
15+
interface RuntimeOptions {
16+
enabled: boolean
17+
verbose: boolean
1318
gistListPageComponent: string
1419
gistPageComponent: string
1520
}
1621

22+
const defaults = {
23+
enabled: true,
24+
verbose: false,
25+
gistPageComponent: '@theme/GistPage',
26+
gistListPageComponent: '@theme/GistListPage',
27+
}
28+
1729
export default async function gists(context: LoadContext, options: Options): Promise<Plugin> {
18-
const { enabled, verbose, personalAccessToken, gistListPageComponent, gistPageComponent } =
19-
options
30+
const { enabled, verbose } = options
31+
32+
// Get token from environment during build time only
33+
const personalAccessToken = process.env.GH_PERSONAL_ACCESS_TOKEN
2034

2135
// Disabled
2236
if (!enabled) return { name: 'docusaurus-plugin-content-gists' }
2337

38+
// Validate token exists and is not empty
39+
if (!personalAccessToken || personalAccessToken.trim() === '') {
40+
throw new Error('GitHub Personal Access Token is required but not provided')
41+
}
42+
43+
// Mask token for logging purposes
44+
const maskedToken =
45+
personalAccessToken.substring(0, 4) +
46+
'...' +
47+
personalAccessToken.substring(personalAccessToken.length - 4)
48+
if (verbose) console.log(`Using GitHub token: ${maskedToken}`)
49+
2450
const api = new GitHub({ personalAccessToken })
2551

52+
// Runtime options (safe to send to client) - exclude sensitive data
53+
const runtimeOptions: RuntimeOptions = {
54+
enabled,
55+
verbose,
56+
gistListPageComponent: defaults.gistListPageComponent,
57+
gistPageComponent: defaults.gistPageComponent,
58+
}
59+
60+
// Note: personalAccessToken is intentionally excluded from runtimeOptions
61+
// to prevent it from being bundled in client code
62+
2663
return {
2764
name: 'docusaurus-plugin-content-gists',
2865

@@ -34,18 +71,20 @@ export default async function gists(context: LoadContext, options: Options): Pro
3471
return '../src/theme'
3572
},
3673

74+
// Build-time data fetching (server-side only)
3775
async loadContent(): Promise<Content> {
3876
if (verbose) console.log('--- Gists ---')
3977

4078
const user = await api.getUsername()
41-
if (verbose) console.log(`Retrieving ${user}'s public gists.`)
79+
if (verbose) console.log(`Retrieving public gists.`)
4280

4381
const gists = await api.getMyGists()
44-
console.log(`Found ${gists.length} public gists for ${user}.`)
82+
if (verbose) console.log(`Found ${gists.length} public gists.`)
4583

4684
return { gists }
4785
},
4886

87+
// Build-time route generation
4988
async contentLoaded({ content, actions }) {
5089
const { gists } = content as { gists: Gists }
5190

@@ -54,28 +93,39 @@ export default async function gists(context: LoadContext, options: Options): Pro
5493

5594
actions.addRoute({
5695
path: `/gists`,
57-
component: gistListPageComponent,
96+
component: runtimeOptions.gistListPageComponent,
5897
modules: {
5998
gists: gistsData,
6099
},
61100
exact: true,
62101
})
63102

64103
// Pages
65-
for (const gistMeta of gists) {
66-
const id = gistMeta.id
67-
68-
const gist = await actions.createData(
69-
`gist-${id}.json`,
70-
JSON.stringify(await api.getGist(id)),
104+
const maxConcurrent = 5 // Process gists in batches to avoid overwhelming the API
105+
for (let i = 0; i < gists.length; i += maxConcurrent) {
106+
const batch = gists.slice(i, i + maxConcurrent)
107+
108+
await Promise.all(
109+
batch.map(async (gistMeta) => {
110+
const id = gistMeta.id
111+
112+
try {
113+
const gistData = await api.getGist(id)
114+
const gist = await actions.createData(`gist-${id}.json`, JSON.stringify(gistData))
115+
116+
actions.addRoute({
117+
path: `/gists/${id}`,
118+
component: runtimeOptions.gistPageComponent,
119+
modules: { gist },
120+
exact: true,
121+
})
122+
} catch (error) {
123+
const message = error instanceof Error ? error.message : 'Unknown error'
124+
console.error(`Failed to process gist ${id}: ${message}`)
125+
// Continue processing other gists even if one fails
126+
}
127+
}),
71128
)
72-
73-
actions.addRoute({
74-
path: `/gists/${id}`,
75-
component: gistPageComponent,
76-
modules: { gist },
77-
exact: true,
78-
})
79129
}
80130
},
81131
}

0 commit comments

Comments
 (0)