לדלג לתוכן

11.1 אבטחת פרונטאנד פתרון

פתרון - אבטחת פרונטאנד

פתרון תרגיל 1 - מניעת XSS

npm install dompurify
npm install -D @types/dompurify
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 - העוגייה נשלחת תמיד, גם מאתרים אחרים. דורשת Secure flag. משמש כשצריך שיתוף בין דומיינים.

4. Preflight Request:

בקשת preflight (OPTIONS) נשלחת אוטומטית על ידי הדפדפן לפני בקשת CORS "מורכבת". הדפדפן שולח אותה כשהבקשה משתמשת ב-method שאינו GET/HEAD/POST, או כוללת headers מותאמים אישית, או Content-Type שאינו מהסוגים הפשוטים. השרת מגיב עם ה-headers המותרים, והדפדפן מחליט אם להמשיך עם הבקשה האמיתית.

5. CSP מפני XSS:

CSP מאפשר לשרת להגדיר מאילו מקורות הדפדפן רשאי לטעון סקריפטים. גם אם תוקף מצליח להזריק תגית <script>, הדפדפן יחסום אותה כי היא לא עומדת במדיניות.

דוגמה:

Content-Security-Policy: script-src 'self'; object-src 'none'

מדיניות זו מאפשרת טעינת סקריפטים רק מהדומיין של האתר עצמו. סקריפטים inline (שמוזרקים ל-HTML) ייחסמו, וכך גם סקריפטים מדומיינים חיצוניים.