11.1 אבטחת פרונטאנד פתרון
פתרון - אבטחת פרונטאנד¶
פתרון תרגיל 1 - מניעת XSS¶
import DOMPurify from 'dompurify';
// הגדרת תגיות ותכונות מותרות
const PURIFY_CONFIG: DOMPurify.Config = {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li', 'code'],
ALLOWED_ATTR: ['href'],
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP: /^https?:\/\//i, // רק http ו-https
};
interface BlogPostProps {
title: string;
content: string; // תוכן HTML מהשרת
author: string;
date: string;
}
function BlogPost({ title, content, author, date }: BlogPostProps) {
// סניטציה של התוכן
const sanitizedContent = DOMPurify.sanitize(content, PURIFY_CONFIG);
return (
<article>
{/* הכותרת והמחבר מוצגים בצורה בטוחה - ריאקט עושה escape אוטומטי */}
<h2>{title}</h2>
<p>
מאת: {author} | {date}
</p>
{/* התוכן עובר סניטציה לפני הצגה */}
<div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />
</article>
);
}
// בדיקה שהקומפוננטה חוסמת קלט זדוני
function TestSanitization() {
const maliciousInputs = [
// תגיות script
'<script>alert("XSS")</script><p>תוכן רגיל</p>',
// אירועי JavaScript
'<img src="x" onerror="alert(\'XSS\')" /><p>תוכן</p>',
// קישורים עם javascript protocol
'<a href="javascript:alert(\'XSS\')">לחץ כאן</a>',
// onclick
'<p onclick="alert(\'XSS\')">לחץ עליי</p>',
// תוכן תקין
'<p>זהו <strong>פוסט</strong> עם <a href="https://example.com">קישור</a></p>',
];
return (
<div>
{maliciousInputs.map((input, index) => (
<div key={index}>
<h3>קלט {index + 1}:</h3>
<BlogPost
title={`פוסט ${index + 1}`}
content={input}
author="משתמש"
date="2024-01-01"
/>
<hr />
</div>
))}
</div>
);
}
export { BlogPost, TestSanitization };
הסבר: DOMPurify מסיר את כל התגיות שלא ברשימה המותרת. תגיות script, אירועי JavaScript כמו onerror ו-onclick, וקישורים עם javascript: protocol - כולם יוסרו אוטומטית.
פתרון תרגיל 2 - אימות קלט עם Zod¶
import { z } from 'zod';
const registrationSchema = z
.object({
username: z
.string()
.min(3, 'שם משתמש חייב להכיל לפחות 3 תווים')
.max(20, 'שם משתמש לא יכול להכיל יותר מ-20 תווים')
.regex(
/^[a-zA-Z0-9_]+$/,
'שם משתמש יכול להכיל רק אותיות באנגלית, מספרים וקו תחתון'
),
email: z
.string()
.min(1, 'אימייל הוא שדה חובה')
.email('כתובת אימייל לא תקינה')
.toLowerCase(),
password: z
.string()
.min(8, 'סיסמה חייבת להכיל לפחות 8 תווים')
.regex(/[A-Z]/, 'סיסמה חייבת להכיל לפחות אות גדולה אחת')
.regex(/[a-z]/, 'סיסמה חייבת להכיל לפחות אות קטנה אחת')
.regex(/[0-9]/, 'סיסמה חייבת להכיל לפחות מספר אחד')
.regex(
/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/,
'סיסמה חייבת להכיל לפחות תו מיוחד אחד'
),
confirmPassword: z.string(),
age: z
.number({ invalid_type_error: 'גיל חייב להיות מספר' })
.int('גיל חייב להיות מספר שלם')
.min(13, 'גיל מינימלי להרשמה הוא 13')
.max(120, 'גיל לא תקין'),
website: z
.string()
.url('כתובת URL לא תקינה')
.optional()
.or(z.literal('')),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: 'חובה לאשר את תנאי השימוש' }),
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'הסיסמאות לא תואמות',
path: ['confirmPassword'],
});
type RegistrationInput = z.infer<typeof registrationSchema>;
// פונקציית אימות שמחזירה שגיאות בעברית
interface ValidationResult {
success: boolean;
data?: RegistrationInput;
errors?: Record<string, string>;
}
function validateRegistration(input: unknown): ValidationResult {
const result = registrationSchema.safeParse(input);
if (result.success) {
return { success: true, data: result.data };
}
// המרת השגיאות למבנה נוח לשימוש
const errors: Record<string, string> = {};
for (const error of result.error.errors) {
const path = error.path.join('.');
errors[path] = error.message;
}
return { success: false, errors };
}
// דוגמת שימוש
const testInput = {
username: 'ab', // קצר מדי
email: 'not-an-email',
password: '12345', // חלשה מדי
confirmPassword: '54321', // לא תואמת
age: 10, // קטן מדי
website: 'not-a-url',
acceptTerms: false,
};
const result = validateRegistration(testInput);
// result.errors:
// {
// username: 'שם משתמש חייב להכיל לפחות 3 תווים',
// email: 'כתובת אימייל לא תקינה',
// password: 'סיסמה חייבת להכיל לפחות 8 תווים',
// age: 'גיל מינימלי להרשמה הוא 13',
// website: 'כתובת URL לא תקינה',
// acceptTerms: 'חובה לאשר את תנאי השימוש',
// }
export { registrationSchema, validateRegistration };
export type { RegistrationInput, ValidationResult };
פתרון תרגיל 3 - הגדרת כותרות אבטחה¶
// next.config.js
const ContentSecurityPolicy = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' https://fonts.googleapis.com",
"img-src 'self' data: https://res.cloudinary.com",
"connect-src 'self' https://api.myapp.com",
"font-src 'self' https://fonts.gstatic.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"upgrade-insecure-requests",
].join('; ');
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: ContentSecurityPolicy,
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(self)',
},
];
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
// החלה על כל הנתיבים
source: '/(.*)',
headers: securityHeaders,
},
];
},
};
module.exports = nextConfig;
הסבר:
- ה-CSP מגביל טעינת משאבים רק ממקורות מאושרים
- HSTS מאלץ חיבור HTTPS למשך שנתיים
- X-Content-Type-Options מונע מהדפדפן לנחש סוגי קבצים
- X-Frame-Options מונע הטמעה ב-iframe (הגנה מ-clickjacking)
- Referrer-Policy שולט במידע שנשלח כשהמשתמש עובר לאתר אחר
- Permissions-Policy חוסם גישה למצלמה ומיקרופון
פתרון תרגיל 4 - שירות אימות מאובטח¶
// services/auth.ts
interface User {
id: string;
name: string;
email: string;
role: string;
}
interface LoginCredentials {
email: string;
password: string;
}
interface AuthResponse {
user: User;
}
class AuthService {
private baseURL = '/api/auth';
async login(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await fetch(`${this.baseURL}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(credentials),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'ההתחברות נכשלה');
}
return response.json();
}
async logout(): Promise<void> {
await fetch(`${this.baseURL}/logout`, {
method: 'POST',
credentials: 'include',
});
}
async getUser(): Promise<User | null> {
try {
const response = await fetch(`${this.baseURL}/me`, {
credentials: 'include',
});
if (!response.ok) return null;
return response.json();
} catch {
return null;
}
}
async refreshToken(): Promise<boolean> {
try {
const response = await fetch(`${this.baseURL}/refresh`, {
method: 'POST',
credentials: 'include',
});
return response.ok;
} catch {
return false;
}
}
}
export const authService = new AuthService();
// context/AuthContext.tsx
'use client';
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from 'react';
import { authService } from '@/services/auth';
interface User {
id: string;
name: string;
email: string;
role: string;
}
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | null>(null);
// מרווח רענון - 14 דקות (טוקן תקף ל-15 דקות)
const REFRESH_INTERVAL = 14 * 60 * 1000;
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// בדיקה ראשונית האם המשתמש מחובר
useEffect(() => {
async function checkAuth() {
try {
const currentUser = await authService.getUser();
setUser(currentUser);
} catch {
setUser(null);
} finally {
setIsLoading(false);
}
}
checkAuth();
}, []);
// רענון אוטומטי של הטוקן
useEffect(() => {
if (!user) return;
const intervalId = setInterval(async () => {
const refreshed = await authService.refreshToken();
if (!refreshed) {
setUser(null);
}
}, REFRESH_INTERVAL);
return () => clearInterval(intervalId);
}, [user]);
const login = useCallback(async (email: string, password: string) => {
const { user: loggedInUser } = await authService.login({ email, password });
setUser(loggedInUser);
}, []);
const logout = useCallback(async () => {
await authService.logout();
setUser(null);
}, []);
return (
<AuthContext.Provider
value={{
user,
isLoading,
isAuthenticated: !!user,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
// דוגמת שימוש בקומפוננטה
'use client';
import { useAuth } from '@/context/AuthContext';
function LoginForm() {
const { login, isLoading, isAuthenticated, user, logout } = useAuth();
if (isLoading) {
return <p>טוען...</p>;
}
if (isAuthenticated) {
return (
<div>
<p>שלום, {user!.name}</p>
<button onClick={logout}>התנתק</button>
</div>
);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string;
const password = formData.get('password') as string;
try {
await login(email, password);
} catch (error) {
alert('ההתחברות נכשלה');
}
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" placeholder="אימייל" required />
<input name="password" type="password" placeholder="סיסמה" required />
<button type="submit">התחבר</button>
</form>
);
}
פתרון תרגיל 5 - הגנת CORS ו-CSRF¶
// lib/csrf.ts
import { randomBytes } from 'crypto';
// יצירת CSRF token
export function generateCSRFToken(): string {
return randomBytes(32).toString('hex');
}
// אימות CSRF token
export function validateCSRFToken(
requestToken: string | null,
sessionToken: string | null
): boolean {
if (!requestToken || !sessionToken) return false;
return requestToken === sessionToken;
}
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { generateCSRFToken, validateCSRFToken } from './lib/csrf';
const ALLOWED_ORIGINS = [
'https://myapp.com',
'https://staging.myapp.com',
'http://localhost:3000',
];
const CSRF_PROTECTED_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH'];
export function middleware(request: NextRequest) {
const origin = request.headers.get('origin') || '';
const response = NextResponse.next();
// --- CORS ---
if (request.nextUrl.pathname.startsWith('/api/')) {
if (ALLOWED_ORIGINS.includes(origin)) {
response.headers.set('Access-Control-Allow-Origin', origin);
response.headers.set(
'Access-Control-Allow-Methods',
'GET, POST, PUT, DELETE, OPTIONS'
);
response.headers.set(
'Access-Control-Allow-Headers',
'Content-Type, Authorization, X-CSRF-Token'
);
response.headers.set('Access-Control-Allow-Credentials', 'true');
response.headers.set('Access-Control-Max-Age', '86400');
}
// טיפול ב-preflight
if (request.method === 'OPTIONS') {
return new NextResponse(null, {
status: 204,
headers: response.headers,
});
}
// --- CSRF ---
if (CSRF_PROTECTED_METHODS.includes(request.method)) {
const csrfFromHeader = request.headers.get('x-csrf-token');
const csrfFromCookie = request.cookies.get('csrf-token')?.value;
if (!validateCSRFToken(csrfFromHeader, csrfFromCookie)) {
return NextResponse.json(
{ error: 'CSRF token invalid' },
{ status: 403 }
);
}
}
}
// יצירת CSRF token חדש אם אין
if (!request.cookies.get('csrf-token')) {
const token = generateCSRFToken();
response.cookies.set('csrf-token', token, {
httpOnly: false, // צריך להיות נגיש מ-JS כדי לשלוח ב-header
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
});
}
return response;
}
export const config = {
matcher: ['/api/:path*', '/((?!_next/static|_next/image|favicon.ico).*)'],
};
// hooks/useSecureFetch.ts
'use client';
import { useCallback } from 'react';
function getCSRFToken(): string {
const match = document.cookie.match(/csrf-token=([^;]+)/);
return match ? match[1] : '';
}
export function useSecureFetch() {
const secureFetch = useCallback(
async (url: string, options: RequestInit = {}) => {
const csrfToken = getCSRFToken();
const headers = new Headers(options.headers);
headers.set('X-CSRF-Token', csrfToken);
if (!headers.has('Content-Type') && options.body) {
headers.set('Content-Type', 'application/json');
}
return fetch(url, {
...options,
headers,
credentials: 'include',
});
},
[]
);
return secureFetch;
}
// שימוש בקומפוננטה
function TransferForm() {
const secureFetch = useSecureFetch();
async function handleTransfer(to: string, amount: number) {
const response = await secureFetch('/api/transfer', {
method: 'POST',
body: JSON.stringify({ to, amount }),
});
if (!response.ok) {
throw new Error('העברה נכשלה');
}
return response.json();
}
return (
<form
onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await handleTransfer(
formData.get('to') as string,
Number(formData.get('amount'))
);
}}
>
<input name="to" placeholder="חשבון יעד" />
<input name="amount" type="number" placeholder="סכום" />
<button type="submit">העבר</button>
</form>
);
}
פתרון תרגיל 6 - סריקת אבטחה אוטומטית¶
# .github/workflows/security-scan.yml
name: Security Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 9 * * 1' # כל יום שני ב-9:00
jobs:
security-audit:
name: Security Audit
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # נדרש עבור truffleHog
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
# בדיקת פגיעויות בתלויות
- name: NPM Audit
run: npm audit --audit-level=high
continue-on-error: false
# בדיקת טייפסקריפט
- name: TypeScript Check
run: npx tsc --noEmit
# בדיקת ESLint עם כללי אבטחה
- name: ESLint Security
run: npx eslint . --config .eslintrc.security.json
# בדיקת secrets בקוד
- name: Scan for secrets
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.repository.default_branch }}
extra_args: --only-verified
# שליחת התראה
- name: Notify on failure
if: failure()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Security scan failed - ${new Date().toISOString().split('T')[0]}`,
body: `The security scan workflow has failed.\n\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
labels: ['security', 'automated']
});
// .eslintrc.security.json - כללי אבטחה נוספים
{
"extends": ["./.eslintrc.json"],
"plugins": ["security"],
"rules": {
"security/detect-object-injection": "warn",
"security/detect-non-literal-regexp": "warn",
"security/detect-unsafe-regex": "error",
"security/detect-buffer-noassert": "error",
"security/detect-eval-with-expression": "error",
"security/detect-no-csrf-before-method-override": "error",
"security/detect-possible-timing-attacks": "warn",
"no-eval": "error",
"no-implied-eval": "error",
"no-new-func": "error"
}
}
הסבר: ה-workflow מכסה ארבעה תחומי אבטחה: פגיעויות בתלויות (npm audit), בטיחות טיפוסים (TypeScript), דפוסי קוד מסוכנים (ESLint security rules), ודליפת סודות (truffleHog). הוא רץ אוטומטית ויוצר issue אם נמצאת בעיה.
תשובות לשאלות¶
1. סוגי XSS:
- Stored XSS - הקוד הזדוני נשמר במסד הנתונים ומוצג לכל משתמש. דוגמה: תוקף כותב תגובה בפורום שמכילה
<script>שגונב עוגיות מכל מי שקורא את התגובה. - Reflected XSS - הקוד הזדוני מגיע כפרמטר ב-URL ומוחזר בתגובת השרת. דוגמה:
https://site.com/search?q=<script>alert(1)</script>- השרת מציג את מונח החיפוש ללא escape. - DOM-based XSS - הקוד הזדוני מבוצע ישירות בצד הלקוח. דוגמה: JavaScript שלוקח ערך מה-URL ומזריק אותו ל-DOM דרך
innerHTML.
2. למה לא לשמור JWT ב-localStorage?
localStorage נגיש מכל קוד JavaScript שרץ בדף. אם יש פגיעות XSS, התוקף יכול לקרוא את הטוקן ולגנוב את ה-session. החלופה המומלצת היא HttpOnly cookie - עוגייה שלא נגישה מ-JavaScript ונשלחת אוטומטית עם כל בקשה לשרת.
3. SameSite attribute:
- strict - העוגייה נשלחת רק כשהמשתמש גולש ישירות לאתר. לא נשלחת כשלוחצים על קישור מאתר אחר. ההגנה החזקה ביותר מפני CSRF.
- lax - העוגייה נשלחת בניווט ראשי (למשל לחיצה על קישור) אבל לא בבקשות צד שלישי (fetch, form submit). ברירת המחדל בדפדפנים מודרניים.
- none - העוגייה נשלחת תמיד, גם מאתרים אחרים. דורשת
Secureflag. משמש כשצריך שיתוף בין דומיינים.
4. Preflight Request:
בקשת preflight (OPTIONS) נשלחת אוטומטית על ידי הדפדפן לפני בקשת CORS "מורכבת". הדפדפן שולח אותה כשהבקשה משתמשת ב-method שאינו GET/HEAD/POST, או כוללת headers מותאמים אישית, או Content-Type שאינו מהסוגים הפשוטים. השרת מגיב עם ה-headers המותרים, והדפדפן מחליט אם להמשיך עם הבקשה האמיתית.
5. CSP מפני XSS:
CSP מאפשר לשרת להגדיר מאילו מקורות הדפדפן רשאי לטעון סקריפטים. גם אם תוקף מצליח להזריק תגית <script>, הדפדפן יחסום אותה כי היא לא עומדת במדיניות.
דוגמה:
מדיניות זו מאפשרת טעינת סקריפטים רק מהדומיין של האתר עצמו. סקריפטים inline (שמוזרקים ל-HTML) ייחסמו, וכך גם סקריפטים מדומיינים חיצוניים.