Skip to content

Commit d88f000

Browse files
committed
security related fixes
1 parent 3390d09 commit d88f000

8 files changed

Lines changed: 300 additions & 102 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,23 @@ jobs:
2525
- name: Install dependencies
2626
run: npm install
2727
- name: Build
28+
# SECURITY: never pass VITE_GITHUB_TOKEN or VITE_OPENROUTER_API_KEY
29+
# here. Vite inlines every VITE_* var at build time; anything set
30+
# here is grep-able in the deployed JS bundle. The production site
31+
# requires the user to sign in with their own GitHub PAT.
2832
env:
29-
VITE_OPENROUTER_API_KEY: ${{ secrets.VITE_OPENROUTER_API_KEY }}
3033
VITE_GITHUB_USERNAME: ${{ github.repository_owner }}
31-
VITE_GITHUB_TOKEN: ${{ secrets.VITE_GITHUB_TOKEN }}
3234
run: npm run build
35+
36+
# Fail loudly if a secret accidentally makes it into dist/. Adjust
37+
# the grep patterns if you rotate to a token with a different prefix.
38+
- name: Assert no secrets in bundle
39+
run: |
40+
set -e
41+
if grep -rE 'github_pat_[A-Za-z0-9_]{20,}|sk-or-v1-[A-Za-z0-9]{20,}' dist/ ; then
42+
echo "::error::A secret was inlined into the built bundle. Aborting deploy."
43+
exit 1
44+
fi
3345
- name: Upload artifact
3446
uses: actions/upload-pages-artifact@v3
3547
with:

index.html

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,43 @@
44
<head>
55
<meta charset="UTF-8" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7-
<!-- Pre-hydration theme swap (avoids FOUC when saved theme is "light") -->
8-
<script>
9-
(function () {
10-
try {
11-
var saved = localStorage.getItem('gitme-theme');
12-
var prefersLight =
13-
window.matchMedia &&
14-
window.matchMedia('(prefers-color-scheme: light)').matches;
15-
var theme = saved || (prefersLight ? 'light' : 'dark');
16-
var r = document.documentElement;
17-
r.classList.remove('light', 'dark');
18-
r.classList.add(theme);
19-
} catch (e) { }
20-
})();
21-
</script>
7+
<!--
8+
Content-Security-Policy — defence-in-depth against XSS / supply-chain
9+
compromise.
10+
11+
script-src is 'self' only (no 'unsafe-inline'). The pre-hydration
12+
theme script is loaded as an external file (theme-init.js) precisely
13+
so we can enforce this. Any future inline script or `eval()`-style
14+
payload from a compromised dependency will be blocked by the browser.
15+
16+
style-src keeps 'unsafe-inline' because React uses `style="…"` props
17+
on elements. That's a style attribute, not a `<style>` element, and
18+
the CSP style-src covers both — Tailwind + React need this.
19+
20+
connect-src is a tight allowlist of the exact hosts the app talks to.
21+
form-action is 'self' to block a compromised page from redirecting
22+
form submissions off-site.
23+
-->
24+
<meta http-equiv="Content-Security-Policy" content="
25+
default-src 'self';
26+
connect-src 'self' https://api.github.com https://openrouter.ai https://www.google.com;
27+
img-src 'self' data: https://avatars.githubusercontent.com https://github.com https://www.google.com https://i1.rgstatic.net;
28+
font-src 'self' data:;
29+
style-src 'self' 'unsafe-inline';
30+
script-src 'self';
31+
frame-src 'self';
32+
object-src 'none';
33+
base-uri 'self';
34+
form-action 'self';
35+
frame-ancestors 'none';
36+
" />
37+
<!-- X-Content-Type-Options via meta is a hint; GH Pages sends it too. -->
38+
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
39+
<meta name="referrer" content="strict-origin-when-cross-origin" />
40+
<!-- Pre-hydration theme swap. External file so CSP can be strict.
41+
Bare relative path — Vite adds the base prefix exactly once for
42+
both dev and production. -->
43+
<script src="theme-init.js"></script>
2244
<meta name="description"
2345
content="GitMe - AI-Powered GitHub Profile Analyzer & Resume Builder. Visualize your developer impact, track contributions, and synthesize professional summaries for recruiters." />
2446
<meta name="keywords"

public/theme-init.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Pre-hydration theme swap. Loaded synchronously in <head> so it runs
2+
// before React paints — avoids FOUC when the user's saved theme differs
3+
// from the default. Kept OUT of index.html so the page CSP can drop
4+
// 'unsafe-inline' from script-src.
5+
(function () {
6+
try {
7+
var saved = localStorage.getItem('gitme-theme');
8+
var prefersLight =
9+
window.matchMedia &&
10+
window.matchMedia('(prefers-color-scheme: light)').matches;
11+
var theme = saved || (prefersLight ? 'light' : 'dark');
12+
var r = document.documentElement;
13+
r.classList.remove('light', 'dark');
14+
r.classList.add(theme);
15+
} catch (e) {
16+
/* private-mode / disabled storage — fall through, default theme wins */
17+
}
18+
})();

src/App.jsx

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
import React, { useState, useEffect, lazy, Suspense } from 'react';
1+
import React, { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react';
22
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
33
import Navbar from './components/Navbar';
44
import GitMeChat from './components/GitMeChat';
55
import Footer from './components/Footer';
66

7+
// Idle-timeout for the in-memory GitHub PAT. After this many minutes of
8+
// inactivity, we wipe state so /profile stops working and an attacker who
9+
// obtains a foothold later (open laptop, XSS from a compromised dep) can't
10+
// find the token in memory. 15 minutes matches common enterprise policy.
11+
const IDLE_WIPE_MINUTES = 15;
12+
713
const LoginPage = lazy(() => import('./pages/LoginPage'));
814
const HomePage = lazy(() => import('./pages/HomePage'));
915
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
@@ -21,21 +27,24 @@ const App = () => {
2127
const [contributionData, setContributionData] = useState(null);
2228
const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false);
2329

24-
// --- Automatic Login ---
30+
// --- Automatic Login (LOCAL DEV ONLY) ---
31+
// SECURITY: production builds must NOT bundle VITE_GITHUB_TOKEN. The CI
32+
// workflow deliberately omits it. This block gates the whole auto-login
33+
// path on `import.meta.env.DEV` so even if the vars leak through some
34+
// other build, the token never gets read at runtime in production.
2535
useEffect(() => {
36+
if (!import.meta.env.DEV) return;
2637
const autoUsername = import.meta.env.VITE_GITHUB_USERNAME;
2738
const autoToken = import.meta.env.VITE_GITHUB_TOKEN;
39+
if (!autoUsername || !autoToken || data || isAutoLoggingIn) return;
2840

29-
if (autoUsername && autoToken && !data && !isAutoLoggingIn) {
30-
setIsAutoLoggingIn(true);
31-
handleLogin(autoUsername, autoToken)
32-
.catch((err) => {
33-
console.error("Auto-login failed:", err);
34-
})
35-
.finally(() => {
36-
setIsAutoLoggingIn(false);
37-
});
38-
}
41+
setIsAutoLoggingIn(true);
42+
handleLogin(autoUsername, autoToken)
43+
.catch(() => {
44+
// Silent — invalid or expired dev token. User can fall back to
45+
// the manual login form.
46+
})
47+
.finally(() => setIsAutoLoggingIn(false));
3948
}, []);
4049

4150

@@ -87,8 +96,8 @@ const App = () => {
8796
if (result.data?.user?.contributionsCollection?.contributionCalendar) {
8897
return { id: period.id, calendar: result.data.user.contributionsCollection.contributionCalendar };
8998
}
90-
} catch (err) {
91-
console.error(`Error fetching calendar for ${period.id}:`, err);
99+
} catch (_err) {
100+
// Silent — a missing calendar year is a soft failure, not fatal.
92101
}
93102
return null;
94103
};
@@ -175,17 +184,57 @@ const App = () => {
175184
const calendars = await fetchContributionCalendar(tok, user, yearsToFetch);
176185
setContributionData({ years: yearsToFetch, calendar: calendars });
177186
} catch (err) {
178-
console.error("Login error:", err);
179-
throw err; // Re-throw so LoginPage can catch it
187+
// Do NOT log err here — some GitHub error responses echo request
188+
// metadata that includes the token. Rethrow with a scrubbed message.
189+
throw new Error(err?.message || 'Sign-in failed. Check your credentials and try again.');
180190
}
181191
};
182192

183-
const handleLogout = () => {
193+
const handleLogout = useCallback(() => {
184194
setData(null);
185195
setToken('');
186196
setUsername('');
187197
setContributionData(null);
188-
};
198+
}, []);
199+
200+
// --- Idle-timeout token wipe -------------------------------------------
201+
// Reset a timer on any user interaction. If IDLE_WIPE_MINUTES elapse
202+
// with no interaction, clear all auth state. Keeps the token from
203+
// living in memory on an unattended tab.
204+
const idleTimerRef = useRef(null);
205+
useEffect(() => {
206+
if (!token) return;
207+
208+
const reset = () => {
209+
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
210+
idleTimerRef.current = setTimeout(
211+
handleLogout,
212+
IDLE_WIPE_MINUTES * 60 * 1000
213+
);
214+
};
215+
216+
const events = ['mousedown', 'keydown', 'scroll', 'touchstart', 'visibilitychange'];
217+
events.forEach((e) => window.addEventListener(e, reset, { passive: true }));
218+
reset();
219+
220+
return () => {
221+
events.forEach((e) => window.removeEventListener(e, reset));
222+
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
223+
};
224+
}, [token, handleLogout]);
225+
226+
// Also wipe on `beforeunload` — belt-and-braces since React state dies
227+
// with the tab anyway, but this covers same-origin navigations.
228+
useEffect(() => {
229+
const wipe = () => {
230+
setToken('');
231+
setUsername('');
232+
setData(null);
233+
setContributionData(null);
234+
};
235+
window.addEventListener('beforeunload', wipe);
236+
return () => window.removeEventListener('beforeunload', wipe);
237+
}, []);
189238

190239
return (
191240
<BrowserRouter basename="/gitme">

src/components/GitMeChat.jsx

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ const MAX_HISTORY = 12; // user + assistant turns kept in context
1717
const MAX_RESPONSE_TOKENS = 500; // cap on assistant reply
1818
const TEMPERATURE = 0.4; // low → grounded, factual
1919

20+
// Per-session rate limit — protects our OpenRouter quota from a single
21+
// visitor spamming the chat. Sliding window of RATE_WINDOW_MS.
22+
const RATE_MAX_REQUESTS = 8;
23+
const RATE_WINDOW_MS = 60_000;
24+
2025
// HTTP header values must be ISO-8859-1 (Latin-1). Strip anything outside
2126
// printable ASCII so header construction never throws — em dashes, curly
2227
// quotes, and other Unicode we use freely in the UI would otherwise break.
@@ -37,15 +42,21 @@ const buildPortfolioBrief = (cfg, ghData) => {
3742
if (!cfg) return '';
3843
const lines = [];
3944

45+
// SECURITY / privacy — do NOT ship email, phone, LinkedIn URL, or the
46+
// calendar meeting link into the third-party model. The model doesn't
47+
// need PII to answer questions; if a visitor asks how to contact the
48+
// owner, we tell it to point them at the on-page Contact section.
4049
lines.push(`# About ${cfg.name}`);
4150
lines.push(`Handle: ${cfg.handle}`);
4251
if (cfg.tagline) lines.push(`Tagline: ${cfg.tagline}`);
4352
if (cfg.location) lines.push(`Location: ${cfg.location}`);
4453
if (cfg.availability) lines.push(`Availability: ${cfg.availability}`);
4554
if (cfg.website) lines.push(`Website: ${cfg.website}`);
46-
if (cfg.email) lines.push(`Email: ${cfg.email}`);
47-
if (cfg.linkedin) lines.push(`LinkedIn: ${cfg.linkedin}`);
4855
if (cfg.github) lines.push(`GitHub: ${cfg.github}`);
56+
lines.push(
57+
'Contact details (email, LinkedIn, calendar link) are visible on the site itself — ' +
58+
'refer visitors to the Contact section rather than quoting them.'
59+
);
4960

5061
if (cfg.bioShort || cfg.bioLong?.length) {
5162
lines.push('');
@@ -243,10 +254,37 @@ const GitMeChat = ({ data }) => {
243254
}
244255
}, [messages, isOpen]);
245256

257+
// In-memory sliding window of recent send timestamps.
258+
// Not persisted — resets when the tab closes.
259+
const rateStampsRef = useRef([]);
260+
246261
const send = async (contentOverride) => {
247262
const raw = (contentOverride ?? input).trim();
248263
if (!raw || isLoading) return;
249264

265+
// Per-session rate limit — keeps a single visitor from burning
266+
// through our OpenRouter quota.
267+
const now = Date.now();
268+
rateStampsRef.current = rateStampsRef.current.filter(
269+
(t) => now - t < RATE_WINDOW_MS
270+
);
271+
if (rateStampsRef.current.length >= RATE_MAX_REQUESTS) {
272+
const waitMs =
273+
RATE_WINDOW_MS - (now - rateStampsRef.current[0]);
274+
const waitSec = Math.max(1, Math.ceil(waitMs / 1000));
275+
setMessages((prev) => [
276+
...prev,
277+
{ role: 'user', content: raw },
278+
{
279+
role: 'assistant',
280+
content: `You're sending messages too quickly. Please wait ${waitSec}s and try again.`,
281+
},
282+
]);
283+
setInput('');
284+
return;
285+
}
286+
rateStampsRef.current.push(now);
287+
250288
const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY?.trim();
251289
if (!apiKey) {
252290
setMessages((prev) => [

0 commit comments

Comments
 (0)