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('\n```');
+ // A