Skip to content

Commit 34f39bc

Browse files
authored
fix: TypeScript strict mode, security hardening, and bug fixes (#545)
* fix: project health cleanup — strict TypeScript, lint fixes, and dead code removal - Enable strict: true in tsconfig.json and ignoreBuildErrors: false in next.config.mjs - Fix all implicit-any errors; install @types/validator and @types/js-yaml - Add type declarations for @glidejs/glide; fix nullable searchParams in CategoryFilter - Remove stale tsconfig includes (pages/, utils/) - Merge duplicate style.css into app/global.css; remove dead font-mono on h1 - Add rate limiting and strict email validation to subscribe/unsubscribe routes - Extract shared Supabase client (cached singleton) to lib/supabase.ts - Extract shared rate limiter with bounded eviction to lib/rate-limit.ts - getClientIp: multi-header detection with 'unknown' fallback (never skips limiting) - Replace import cn from 'clsx' with import { cn } from '@/lib/utils' in 3 components - Fix all ESLint errors in e2e tests; add ESLint config section for e2e/ files - Fix hardcoded paths in e2e tests to relative paths; fix /tmp/ → test-results/ - Fix behavior: 'instant' to 'auto' in scroll calls (invalid ScrollBehavior type) - Fix boundingBox null checks and clamp negative clip coordinates - Mark debug-only e2e tests as test.skip; add assertions to dark mode tests - Move ad-hoc check-redirect.js to scripts/check-redirect.cjs - Fix lint-staged: remove broken ESLINT_USE_FLAT_CONFIG=false - Rename scripts/clean-cache.ts to .cjs (uses require(), not ESM) - Remove dead Companies import from components/home/index.tsx - Update generate_embeddings workflow to actions v4, Node 20, pnpm/action-setup@v4 - Move hardcoded Supabase URL to ${{ secrets.SUPABASE_URL }} - Delete bun.lockb, 8 orphaned root PNGs; clean up .gitignore - Disable X-Powered-By header (poweredByHeader: false) * fix: open in GitHub button links to correct repo and source file The dropdown 'Open in GitHub' button was hardcoded to danny-avila/LibreChat (the app repo, repo root). Now points to LibreChat-AI/librechat.ai and links directly to the source MDX file for the current page. * fix(security): validate feedback server action and sanitize Scarf pixel - Feedback action: validate opinion ('good'|'bad' only), enforce relative URL (no open redirects), cap message at 2000 chars - Use shared createRateLimiter from lib/rate-limit.ts (3/URL/60s) - Scarf pixel: sanitize NEXT_PUBLIC_SCARF_PIXEL_ID with /^[\w-]+$/ before interpolating into dangerouslySetInnerHTML to prevent XSS
1 parent 4174c8d commit 34f39bc

48 files changed

Lines changed: 1587 additions & 193 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/generate_embeddings.yml

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,22 @@ jobs:
99
generate:
1010
runs-on: ubuntu-latest
1111
steps:
12-
- uses: actions/checkout@v3
12+
- uses: actions/checkout@v4
1313

1414
- name: Set up Node.js
15-
uses: actions/setup-node@v3
15+
uses: actions/setup-node@v4
1616
with:
17-
node-version: '18'
17+
node-version: '20'
1818

19-
- name: Install pnpm
20-
run: npm install -g pnpm
21-
22-
- name: Install dependencies
23-
run: pnpm install
19+
- uses: pnpm/action-setup@v4
20+
name: Install pnpm
21+
with:
22+
run_install: true
2423

2524
- name: supabase-embeddings-generator
2625
uses: supabase/embeddings-generator@v0.0.5
2726
with:
28-
supabase-url: 'https://gnoyckdqaktttbfurtcv.supabase.co'
27+
supabase-url: ${{ secrets.SUPABASE_URL }}
2928
supabase-service-role-key: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
3029
openai-key: ${{ secrets.OPENAI_API_KEY }}
31-
docs-root-path: './content/docs'
30+
docs-root-path: './content/docs'

.gitignore

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ next-env.d.ts
66
.env.local
77
.env
88
.env*.local
9-
.git
109

1110
# next sitemap
1211
public/robots.txt
@@ -15,7 +14,7 @@ public/sitemap*.xml
1514
# we use pnpm
1615
package-lock.json
1716
yarn.lock
18-
17+
bun.lockb
1918

2019
# Mac
2120
.DS_Store
@@ -24,7 +23,6 @@ yarn.lock
2423
.eslintcache
2524

2625
# Testing & debugging artifacts
27-
playwright.config.ts
2826
test-results/
2927
playwright-report/
3028
tests/
@@ -40,15 +38,20 @@ sidebar-final-audit/
4038
/.codeium
4139
*.local.md
4240

41+
# Background shell state
42+
.bg-shell/
43+
44+
# Planning artifacts
45+
.planning/
46+
e2e/.planning/
47+
4348
# Claude Flow generated files
4449
.claude/settings.local.json
4550
.mcp.json
4651
claude-flow.config.json
4752
.swarm/
4853
.hive-mind/
4954
.claude-flow/
50-
memory/
51-
coordination/
5255
memory/claude-flow-data.json
5356
memory/sessions/*
5457
!memory/sessions/README.md
@@ -64,6 +67,4 @@ coordination/orchestration/*
6467
*.sqlite-journal
6568
*.sqlite-wal
6669
claude-flow
67-
# Removed Windows wrapper files per user request
6870
hive-mind-prompt-*.txt
69-
e2e/.planning/

app/actions/feedback.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,39 @@ interface FeedbackPayload {
77
}
88

99
const GITHUB_GRAPHQL = 'https://api.github.com/graphql'
10+
const MAX_MESSAGE_LENGTH = 2000
11+
const VALID_OPINIONS = new Set(['good', 'bad'])
12+
13+
import { createRateLimiter } from '@/lib/rate-limit'
14+
15+
const isRateLimited = createRateLimiter(3, 60_000)
16+
17+
/**
18+
* Validate and sanitize the feedback payload.
19+
* Returns null if invalid, or the sanitized payload.
20+
*/
21+
function validatePayload(
22+
raw: unknown,
23+
): { opinion: 'good' | 'bad'; message: string; url: string } | null {
24+
if (!raw || typeof raw !== 'object') return null
25+
26+
const { opinion, message, url } = raw as Record<string, unknown>
27+
28+
// Opinion must be exactly 'good' or 'bad'
29+
if (typeof opinion !== 'string' || !VALID_OPINIONS.has(opinion)) return null
30+
31+
// URL must be a relative docs path (no external URLs / open redirect)
32+
if (typeof url !== 'string' || !url.startsWith('/') || url.includes('//')) return null
33+
34+
// Message is optional but must be a string with bounded length
35+
const safeMessage = typeof message === 'string' ? message.slice(0, MAX_MESSAGE_LENGTH).trim() : ''
36+
37+
return {
38+
opinion: opinion as 'good' | 'bad',
39+
message: safeMessage,
40+
url: url.slice(0, 500),
41+
}
42+
}
1043

1144
async function createGitHubDiscussion(payload: FeedbackPayload): Promise<void> {
1245
const token = process.env.GITHUB_FEEDBACK_TOKEN
@@ -86,8 +119,19 @@ async function postToDiscord(payload: FeedbackPayload): Promise<void> {
86119
}
87120
}
88121

89-
export async function submitFeedback(payload: FeedbackPayload): Promise<{ success: boolean }> {
122+
export async function submitFeedback(raw: FeedbackPayload): Promise<{ success: boolean }> {
90123
try {
124+
// Validate before rate limiting to avoid polluting the rate limit map with junk keys
125+
const payload = validatePayload(raw)
126+
if (!payload) {
127+
return { success: false }
128+
}
129+
130+
// Rate limit by validated URL to prevent spamming the same page
131+
if (isRateLimited(payload.url)) {
132+
return { success: false }
133+
}
134+
91135
await Promise.allSettled([createGitHubDiscussion(payload), postToDiscord(payload)])
92136
return { success: true }
93137
} catch (err) {

app/api/subscribe/route.ts

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,22 @@
11
import { NextResponse } from 'next/server'
2-
import { createClient } from '@supabase/supabase-js'
2+
import { getSupabaseClient, isValidEmail, normalizeEmail } from '@/lib/supabase'
3+
import { createRateLimiter, getClientIp } from '@/lib/rate-limit'
34

4-
function isValidEmail(email: string): boolean {
5-
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
6-
}
7-
8-
function getSupabaseClient() {
9-
const url = process.env.SUPABASE_URL
10-
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
11-
12-
if (!url || !key) {
13-
return null
14-
}
15-
16-
return createClient(url, key)
17-
}
5+
const isRateLimited = createRateLimiter(5, 60_000)
186

197
export async function POST(request: Request) {
208
try {
21-
const body = await request.json()
22-
const { email } = body
9+
const ip = getClientIp(request)
10+
if (isRateLimited(ip)) {
11+
return NextResponse.json({ message: 'Too many requests' }, { status: 429 })
12+
}
13+
14+
const body: unknown = await request.json()
15+
if (!body || typeof body !== 'object' || !('email' in body)) {
16+
return NextResponse.json({ message: 'Valid email is required' }, { status: 422 })
17+
}
18+
19+
const { email } = body as { email: unknown }
2320

2421
if (!email || typeof email !== 'string' || !isValidEmail(email)) {
2522
return NextResponse.json({ message: 'Valid email is required' }, { status: 422 })
@@ -28,20 +25,19 @@ export async function POST(request: Request) {
2825
const supabase = getSupabaseClient()
2926

3027
if (!supabase) {
31-
// Supabase is not configured; return a placeholder success response
3228
return NextResponse.json(
3329
{ message: 'Subscription service is not configured' },
3430
{ status: 503 },
3531
)
3632
}
3733

38-
const normalizedEmail = email.toLowerCase().trim()
34+
const normalized = normalizeEmail(email)
3935

4036
// Check if already subscribed
4137
const { data: existing } = await supabase
4238
.from('subscribers')
4339
.select('id, status')
44-
.eq('email', normalizedEmail)
40+
.eq('email', normalized)
4541
.single()
4642

4743
if (existing) {
@@ -50,18 +46,15 @@ export async function POST(request: Request) {
5046
}
5147

5248
// Re-subscribe if previously unsubscribed
53-
await supabase
54-
.from('subscribers')
55-
.update({ status: 'subscribed' })
56-
.eq('email', normalizedEmail)
49+
await supabase.from('subscribers').update({ status: 'subscribed' }).eq('email', normalized)
5750

5851
return NextResponse.json({ message: 'Subscription successful' }, { status: 200 })
5952
}
6053

6154
// Insert new subscriber
6255
const { error } = await supabase
6356
.from('subscribers')
64-
.insert({ email: normalizedEmail, status: 'subscribed' })
57+
.insert({ email: normalized, status: 'subscribed' })
6558

6659
if (error) {
6760
console.error('Subscription error:', error.message)

app/api/unsubscribe/route.ts

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,22 @@
11
import { NextResponse } from 'next/server'
2-
import { createClient } from '@supabase/supabase-js'
2+
import { getSupabaseClient, isValidEmail, normalizeEmail } from '@/lib/supabase'
3+
import { createRateLimiter, getClientIp } from '@/lib/rate-limit'
34

4-
function isValidEmail(email: string): boolean {
5-
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
6-
}
7-
8-
function getSupabaseClient() {
9-
const url = process.env.SUPABASE_URL
10-
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
11-
12-
if (!url || !key) {
13-
return null
14-
}
15-
16-
return createClient(url, key)
17-
}
5+
const isRateLimited = createRateLimiter(5, 60_000)
186

197
export async function POST(request: Request) {
208
try {
21-
const body = await request.json()
22-
const { email } = body
9+
const ip = getClientIp(request)
10+
if (isRateLimited(ip)) {
11+
return NextResponse.json({ message: 'Too many requests' }, { status: 429 })
12+
}
13+
14+
const body: unknown = await request.json()
15+
if (!body || typeof body !== 'object' || !('email' in body)) {
16+
return NextResponse.json({ message: 'Invalid email format' }, { status: 400 })
17+
}
18+
19+
const { email } = body as { email: unknown }
2320

2421
if (!email || typeof email !== 'string' || !isValidEmail(email)) {
2522
return NextResponse.json({ message: 'Invalid email format' }, { status: 400 })
@@ -34,12 +31,12 @@ export async function POST(request: Request) {
3431
)
3532
}
3633

37-
const normalizedEmail = email.toLowerCase().trim()
34+
const normalized = normalizeEmail(email)
3835

3936
const { data: subscriber, error: fetchError } = await supabase
4037
.from('subscribers')
4138
.select('id, status')
42-
.eq('email', normalizedEmail)
39+
.eq('email', normalized)
4340
.single()
4441

4542
if (fetchError || !subscriber) {
@@ -49,7 +46,7 @@ export async function POST(request: Request) {
4946
const { error: updateError } = await supabase
5047
.from('subscribers')
5148
.update({ status: 'unsubscribed' })
52-
.eq('email', normalizedEmail)
49+
.eq('email', normalized)
5350

5451
if (updateError) {
5552
console.error('Unsubscription error:', updateError.message)

app/docs/[[...slug]]/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ export default async function Page(props: PageProps) {
3333
<DocsDescription>{page.data.description}</DocsDescription>
3434
<div className="flex flex-row gap-2 items-center border-b pt-2 pb-6">
3535
<LLMCopyButton markdownUrl={`${page.url}.mdx`} />
36-
<ViewOptions markdownUrl={`${page.url}.mdx`} />
36+
<ViewOptions
37+
markdownUrl={`${page.url}.mdx`}
38+
githubUrl={`https://github.com/LibreChat-AI/librechat.ai/blob/main/content/docs/${page.file.path}`}
39+
/>
3740
</div>
3841
<DocsBody>
3942
<MDX components={mdxComponents} />

app/global.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,23 @@
6262
@apply bg-background text-foreground;
6363
font-family: var(--font-geist-sans), system-ui, sans-serif;
6464
}
65+
66+
footer {
67+
@apply bg-background text-foreground;
68+
font-family: var(--font-geist-sans), system-ui, sans-serif;
69+
}
70+
71+
h1 {
72+
@apply tracking-tight leading-tight;
73+
font-family: var(--font-geist-sans), system-ui, sans-serif;
74+
}
75+
76+
h2 {
77+
@apply tracking-tight;
78+
font-family: var(--font-geist-sans), system-ui, sans-serif;
79+
}
80+
}
81+
82+
.social-svg {
83+
border-radius: 10px !important;
6584
}

app/layout.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ export const metadata: Metadata = {
3434
}
3535

3636
export default function RootLayout({ children }: { children: ReactNode }) {
37+
// Sanitize Scarf pixel ID: only allow alphanumeric chars, underscores, and hyphens
38+
// to prevent XSS via dangerouslySetInnerHTML injection if the env var is compromised.
39+
const rawScarfId = process.env.NEXT_PUBLIC_SCARF_PIXEL_ID ?? ''
40+
const scarfPixelId = /^[\w-]+$/.test(rawScarfId) ? rawScarfId : ''
41+
3742
return (
3843
<html
3944
lang="en"
@@ -44,14 +49,14 @@ export default function RootLayout({ children }: { children: ReactNode }) {
4449
<Provider>{children}</Provider>
4550
<Analytics />
4651
<SpeedInsights />
47-
{process.env.NEXT_PUBLIC_SCARF_PIXEL_ID && (
52+
{scarfPixelId && (
4853
<Script
4954
id="scarf-pixel"
5055
strategy="afterInteractive"
5156
dangerouslySetInnerHTML={{
5257
__html: `
5358
(function () {
54-
var PIXEL_ID = '${process.env.NEXT_PUBLIC_SCARF_PIXEL_ID}';
59+
var PIXEL_ID = '${scarfPixelId}';
5560
function sendScarfPing() {
5661
var img = new Image();
5762
img.referrerPolicy = 'no-referrer-when-downgrade';

bun.lockb

-479 KB
Binary file not shown.

components.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"rsc": true,
55
"tailwind": {
66
"config": "tailwind.config.js",
7-
"css": "style.css",
7+
"css": "app/global.css",
88
"baseColor": "slate",
99
"cssVariables": true
1010
},

0 commit comments

Comments
 (0)