Skip to content

Commit 40a3534

Browse files
committed
Security Fixes Implemented
β€” Request throttle: Added 3-second minimum gap between AI requests (lastRequestTime ref) as a client-side layer on top of the worker's rate limiting. β€” AI flashcard answers: Added parseMarkdown() pass on all Gemini flashcard answers before storage. Changed the worker's flashcard system prompt to request markdown (**bold**) instead of raw HTML (<strong>) β€” matching how chat responses are already handled, no new dependency needed. β€” User messages: Added escapeHtml() helper and applied it before storing user input. A user typing <img src=x onerror=alert(1)> now renders as escaped text, not executable HTML. - New file that Cloudflare Pages/Netlify serves as HTTP response headers - Production only allows notes.gobinath.com origins. localhost origins are enabled only when ENVIRONMENT=development (read from worker/.dev.vars via wrangler dev). The .dev.vars file is added to .gitignore.
1 parent f8b54a5 commit 40a3534

6 files changed

Lines changed: 77 additions & 8 deletions

File tree

β€Ž.gitignoreβ€Ž

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,5 @@ coverage/
4848
*~
4949

5050
# Wrangler
51-
worker/.wrangler/
51+
worker/.wrangler/
52+
worker/.dev.vars

β€Ž.vitepress/theme/components/AIChatBot.vueβ€Ž

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const flashcards = ref([])
1515
const loading = ref(false)
1616
const hasInteracted = ref(false)
1717
const chatBody = ref(null)
18+
const lastRequestTime = ref(0)
19+
const MIN_REQUEST_INTERVAL = 3000
1820
1921
const isStudyPage = computed(() => {
2022
const path = route.path
@@ -59,6 +61,15 @@ function extractPageContent() {
5961
return text.slice(0, 40_000)
6062
}
6163
64+
function escapeHtml(str) {
65+
return str
66+
.replace(/&/g, '&amp;')
67+
.replace(/</g, '&lt;')
68+
.replace(/>/g, '&gt;')
69+
.replace(/"/g, '&quot;')
70+
.replace(/'/g, '&#39;')
71+
}
72+
6273
function parseMarkdown(md) {
6374
let html = md
6475
// Escape HTML entities first (security)
@@ -111,8 +122,12 @@ async function sendQuestion(q) {
111122
const userQuestion = q || question.value.trim()
112123
if (!userQuestion || loading.value) return
113124
125+
const now = Date.now()
126+
if (now - lastRequestTime.value < MIN_REQUEST_INTERVAL) return
127+
lastRequestTime.value = now
128+
114129
question.value = ''
115-
messages.value.push({ role: 'user', text: userQuestion })
130+
messages.value.push({ role: 'user', text: escapeHtml(userQuestion) })
116131
loading.value = true
117132
await scrollToBottom()
118133
@@ -142,6 +157,11 @@ async function sendQuestion(q) {
142157
143158
async function generateFlashcards() {
144159
if (loading.value) return
160+
161+
const now = Date.now()
162+
if (now - lastRequestTime.value < MIN_REQUEST_INTERVAL) return
163+
lastRequestTime.value = now
164+
145165
loading.value = true
146166
flashcards.value = []
147167
@@ -156,9 +176,12 @@ async function generateFlashcards() {
156176
})
157177
const data = await res.json()
158178
if (data.error) {
159-
flashcards.value = [{ question: 'Error', answer: data.error }]
179+
flashcards.value = [{ question: 'Error', answer: escapeHtml(data.error) }]
160180
} else {
161-
flashcards.value = data.flashcards
181+
flashcards.value = data.flashcards.map(c => ({
182+
question: c.question,
183+
answer: parseMarkdown(c.answer),
184+
}))
162185
}
163186
} catch {
164187
flashcards.value = [{ question: 'Error', answer: 'Failed to reach the AI service.' }]

β€ŽCONTRIBUTING.mdβ€Ž

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,40 @@ npm run docs:build # Build static files to .vitepress/dist
4545
npm run docs:preview # Preview the production build
4646
```
4747

48+
### AI Study Assistant (Worker Development)
49+
50+
The AI chatbot uses a Cloudflare Worker as a proxy to Google Gemini. The frontend at `http://localhost:5173` calls the **deployed** production worker by default β€” you don't need to run the worker locally just to work on content or styling.
51+
52+
If you want to test AI features locally (against a local worker), follow these steps:
53+
54+
**Prerequisites**: [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) (`npm install -g wrangler`) and a [Google Gemini API key](https://aistudio.google.com/app/apikey).
55+
56+
**1. Create `worker/.dev.vars`** (this file is gitignored β€” never commit it):
57+
```
58+
ENVIRONMENT=development
59+
GEMINI_API_KEY=your-gemini-api-key-here
60+
```
61+
62+
**2. Run the worker locally:**
63+
```bash
64+
cd worker
65+
npm install
66+
wrangler dev
67+
```
68+
The worker runs at `http://localhost:8787` by default.
69+
70+
**3. Point the frontend at your local worker:**
71+
72+
In `.vitepress/theme/components/AIChatBot.vue`, temporarily change:
73+
```js
74+
const WORKER_URL = 'http://localhost:8787'
75+
```
76+
**Revert this change before submitting a PR.**
77+
78+
**Why `.dev.vars`?**
79+
80+
The worker uses environment-gated CORS. In production (`ENVIRONMENT=production`, set in `wrangler.toml`), only `notes.gobinath.com` is allowed as a request origin. Setting `ENVIRONMENT=development` in `.dev.vars` tells the worker to also accept `localhost` origins when running via `wrangler dev`. The `.dev.vars` file is read automatically by `wrangler dev` and is gitignored to prevent secrets from being committed.
81+
4882
---
4983

5084
## Project Structure

β€Žpublic/_headersβ€Ž

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/*
2+
X-Content-Type-Options: nosniff
3+
X-Frame-Options: DENY
4+
Referrer-Policy: strict-origin-when-cross-origin
5+
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
6+
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://cloud.umami.is; style-src 'self' 'unsafe-inline'; connect-src 'self' https://gemini-proxy.gobinathm.workers.dev https://api-gateway.umami.dev; img-src 'self' data: https:; font-src 'self'; frame-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'

β€Žworker/src/index.jsβ€Ž

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@
44
* Handles CORS, rate limiting, and input validation.
55
*/
66

7-
const ALLOWED_ORIGINS = [
7+
const PROD_ORIGINS = [
88
'https://notes.gobinath.com',
99
'https://gobinathmallaiyan.github.io',
10-
'http://localhost:5173',
11-
'http://localhost:4173',
1210
];
1311

1412
const GEMINI_URL =
@@ -104,7 +102,7 @@ function buildFlashcardPrompt(pageContent) {
104102
system_instruction: {
105103
parts: [
106104
{
107-
text: `You are a flashcard generator for certification exam preparation. Given study notes, create 8-12 high-quality flashcards that test key concepts. Return ONLY a valid JSON array of objects with "question" (string) and "answer" (string with HTML formatting like <strong>, <em>, <br>, <ul><li>). Focus on: definitions, comparisons, decision rules, and exam-relevant facts. Do not wrap the JSON in markdown code fences.`,
105+
text: `You are a flashcard generator for certification exam preparation. Given study notes, create 8-12 high-quality flashcards that test key concepts. Return ONLY a valid JSON array of objects with "question" (string) and "answer" (string using markdown formatting like **bold**, *italic*, and - bullet points for lists). Focus on: definitions, comparisons, decision rules, and exam-relevant facts. Do not wrap the JSON in markdown code fences.`,
108106
},
109107
],
110108
},
@@ -127,6 +125,10 @@ function buildFlashcardPrompt(pageContent) {
127125

128126
export default {
129127
async fetch(request, env) {
128+
const ALLOWED_ORIGINS = env.ENVIRONMENT === 'development'
129+
? [...PROD_ORIGINS, 'http://localhost:5173', 'http://localhost:4173']
130+
: PROD_ORIGINS;
131+
130132
const origin = request.headers.get('Origin') || '';
131133
const allowedOrigin = ALLOWED_ORIGINS.find((o) => origin.startsWith(o)) || ALLOWED_ORIGINS[0];
132134

β€Žworker/wrangler.tomlβ€Ž

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@ workers_dev = true
55

66
# Secret: GEMINI_API_KEY
77
# Set via: wrangler secret put GEMINI_API_KEY
8+
9+
[vars]
10+
ENVIRONMENT = "production"

0 commit comments

Comments
Β (0)