diff --git a/src/components/auth/SignInForm/SignInForm.tsx b/src/components/auth/SignInForm/SignInForm.tsx index 49ca89c2..a59ed879 100644 --- a/src/components/auth/SignInForm/SignInForm.tsx +++ b/src/components/auth/SignInForm/SignInForm.tsx @@ -52,24 +52,13 @@ export default function SignInForm({ return; } - // Check server-side rate limit (REQ-SEC-003) - // Fail-open: if rate limit check fails, allow sign-in attempt - let rateLimit: { - allowed: boolean; - remaining: number; - locked_until?: string | null; - } = { - allowed: true, - remaining: 5, - }; - try { - rateLimit = await checkRateLimit(email, 'sign_in'); - } catch (rateLimitError) { - logger.warn('Rate limit check failed, allowing sign-in attempt', { - error: rateLimitError, - }); - // Continue with sign-in (fail-open for UX) - } + // Check server-side rate limit (REQ-SEC-003). checkRateLimit is + // fail-CLOSED by design (it returns allowed:false if the rate-limit + // infrastructure is down, to prevent brute force) — matching SignUpForm + // and ForgotPasswordForm, which also call it directly. Do NOT wrap it in a + // fail-open catch: that would silently disable brute-force protection on a + // backend outage, contradicting the wrapper's documented policy. + const rateLimit = await checkRateLimit(email, 'sign_in'); if (!rateLimit.allowed) { const timeUntilReset = rateLimit.locked_until diff --git a/src/components/molecular/SEOAnalysisPanel/SEOAnalysisPanel.tsx b/src/components/molecular/SEOAnalysisPanel/SEOAnalysisPanel.tsx index ee469dfb..b949e4d3 100644 --- a/src/components/molecular/SEOAnalysisPanel/SEOAnalysisPanel.tsx +++ b/src/components/molecular/SEOAnalysisPanel/SEOAnalysisPanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import type { BlogPost } from '@/types/blog'; import { seoAnalyzer } from '@/lib/blog/seo-analyzer'; @@ -26,7 +26,9 @@ export default function SEOAnalysisPanel({ onToggle, }: SEOAnalysisPanelProps) { const [copied, setCopied] = useState(false); - const analysis = seoAnalyzer.analyze(post); + // analyze() runs regex/split over the full post body — memoize so it doesn't + // re-run on unrelated re-renders (e.g. the copied-toggle). + const analysis = useMemo(() => seoAnalyzer.analyze(post), [post]); const { score, suggestions, strengths, weaknesses } = analysis; const copyFeedback = () => { diff --git a/src/config/blog.config.ts b/src/config/blog.config.ts deleted file mode 100644 index 4644e2c7..00000000 --- a/src/config/blog.config.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Blog System Configuration - * Central configuration for the blog system including storage limits, - * sync intervals, and cache settings - */ - -export const blogConfig = { - // Storage Limits - storage: { - textLimit: 5 * 1024 * 1024, // 5MB for text content - imageLimit: 200 * 1024 * 1024, // 200MB for images - maxPostSize: 1024 * 1024, // 1MB per post - compressionThreshold: 100 * 1024, // Compress posts larger than 100KB - }, - - // Sync Configuration - sync: { - intervalMs: 30000, // Sync every 30 seconds when online - batchSize: 10, // Process 10 items per sync batch - maxRetries: 3, // Maximum retry attempts for failed syncs - retryDelayMs: 5000, // Wait 5 seconds between retries - conflictStrategy: 'three-way-merge' as const, // Conflict resolution strategy - }, - - // Cache Configuration - cache: { - ttlMs: 7 * 24 * 60 * 60 * 1000, // Cache for 7 days - maxEntries: 100, // Maximum cached entries - cleanupIntervalMs: 60 * 60 * 1000, // Clean up every hour - }, - - // Content Processing - processing: { - tocMinHeadings: 3, // Minimum headings for TOC generation - tocMaxDepth: 3, // Maximum heading depth for TOC - excerptLength: 160, // Characters for auto-generated excerpts - readingWordsPerMinute: 200, // For reading time calculation - syntaxHighlightTheme: 'prism-tomorrow', // Prism.js theme - maxCodeBlockSize: 10 * 1024, // 10KB max for syntax highlighting - }, - - // Pagination - pagination: { - postsPerPage: 10, - maxPageButtons: 5, // Maximum pagination buttons to show - }, - - // Feature Flags - features: { - enableComments: false, // Comments system not implemented yet - enableSearch: true, // Enable search functionality - enableRss: true, // Enable RSS feed generation - enableSocialSharing: true, // Enable social share buttons - enableOfflineEditing: true, // Enable offline editing capabilities - enableConflictResolution: true, // Enable conflict resolution UI - }, - - // SEO Configuration - seo: { - titleTemplate: '%s | Blog', - defaultDescription: 'A modern blog with offline-first capabilities', - defaultImage: '/opengraph-image.png', - twitterHandle: '@yourtwitterhandle', - }, - - // Author Registry - authors: { - registryPath: '/src/data/authors.json', - defaultAvatar: '/avatars/default.png', - maxBioLength: 500, - maxSocialLinks: 10, - }, - - // Validation - validation: { - slugPattern: /^[a-z0-9-]+$/, - tagPattern: /^[a-z0-9-]+$/, - maxTitleLength: 200, - maxTags: 10, - maxCategories: 5, - requiredFrontmatterFields: ['title', 'date', 'author'], - }, -}; - -export type BlogConfig = typeof blogConfig; diff --git a/src/lib/blog/markdown-processor.test.ts b/src/lib/blog/markdown-processor.test.ts index 5579df21..bd430fe7 100644 --- a/src/lib/blog/markdown-processor.test.ts +++ b/src/lib/blog/markdown-processor.test.ts @@ -276,6 +276,55 @@ describe('MarkdownProcessor code block extraction', () => { }); }); +describe('MarkdownProcessor raw-HTML XSS hardening', () => { + it('strips world'); + expect(html).not.toContain(',
' + ); + expect(html).not.toMatch(/<(iframe|style|object|form)\b/i); + }); + + it('removes inline event-handler attributes', () => { + const { html } = process(' text'); + expect(html.toLowerCase()).not.toContain('onerror'); + expect(html).not.toContain('alert(1)'); + }); + + it('neutralizes javascript: URIs in raw href/src', () => { + const { html } = process('x'); + expect(html.toLowerCase()).not.toContain('javascript:'); + }); + + it('does NOT strip the benign structural tags the blog authors use', () => { + const md = + '
hi
\n
morebody
\nline
break'; + const { html } = process(md); + expect(html).toContain(''); + expect(html).toContain('
'); + expect(html).toContain(''); + expect(html).toContain(' sample', () => { + const { html } = process('```html\n\n```'); + // A