מניעת הורדת משאבים באופן לא מורשה מהשרת שלכם ללא Access Control

איך אנו יוצרים תכנון של מערכת שצריכה לאפשר הורדה של קבצים אבל עדיין מגינים על הקבצים האלו מהורדות לא מאושרות?
DALL·E 2024-01-20 11.16.08 - A detailed illustration of a modern web architecture. The architecture includes a user interface layer with web browsers and mobile devices, a middle

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

אז הנה המקרה: יש לנו אתר המנגיש מידע באופן פומבי – בין אם כתמונות, , קבצי PDF או קבצים אחרים או בעצם כל משאב שהוא שנמצא אצלנו.אנחנו כן רוצים לאפשר הורדה דרך האתר, אבל לא באמצעים אחרים. למה? כדי למנוע למשל הורדה מקישורים ישירים מגוגל או למנוע מאתרים אחרים להציב קישורים לנכסים היקרים שלי. או כדי למנוע כרייה או ניתוח של הנתונים.

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

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

בדיקה של referrer

זו שיטה מאד אפקטיבית ופשוטה ליישום ומעולה למניעת hotlinking (שיטה נלוזה שהיתה מאד נפוצה בסוף שנות ה-90/תחילת ה-2000 ובה אתרי אינטרנט השתמשו ב-src ממקור אחר כדי לחסוך בכסף של אחסון). אני אדגים אותה עם Node.js ןאקספרס די קלאסי למשל:

const express = require('express');
const app = express();

const PORT = 3000;

app.use((req, res, next) => {
    const allowedReferrer = 'https://your-allowed-referrer.com';

    // Check the referer header
    if (req.headers.referer === allowedReferrer) {
        next(); // Proceed if the referrer matches
    } else {
        res.status(403).send('Access Denied: Invalid Referrer');
    }
});

app.get('/protected-resource', (req, res) => {
    res.send('This is a protected resource');
});

app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
});

הקוד הוא די straight forward ושמתי אותו באמת רק להראות כמה זה פשוט – לא ממש צריך להבין Node.js על מנת להבין איך זה עובד וכמובן שניתן לממש את זה בכל שפה שהיא. אגב, לא חייבים לעשות את זה בכלל אפליקטיבי (כלומר בקוד) אלא בתשתית. בד״כ בשרתי אפשר לעשות את זה בהגדרות, למשל ב-Apache (כן, כן) אפשר לעשות את זה עם apache2.conf או עם .htaccess. הנה דוגמה שג׳ינטרתי מהר:

RewriteEngine on
RewriteCond %{HTTP_REFERER} !^http://(www\.)?your-allowed-domain\.com [NC]
RewriteCond %{HTTP_REFERER} !^http://(www\.)?your-allowed-domain\.com.*$ [NC]
RewriteCond %{HTTP_REFERER} !^https://(www\.)?your-allowed-domain\.com [NC]
RewriteCond %{HTTP_REFERER} !^https://(www\.)?your-allowed-domain\.com.*$ [NC]
RewriteCond %{HTTP_REFERER} !^$
RewriteRule \.(jpg|jpeg|png|gif|zip|pdf)$ - [F]

אפשר לעשות את זה בכל שרת, nginx, אפילו בטומקאט (עם פילטרים). מי שרוצה לדעת יותר על htaccess יכול לקרוא פה.

מה הבעיה עם הדרך הזו? שלמרות שקל ליישם אותה, היא לא מאד אפקטיבית. כלומר אפקטיבית למי שינסה להוריד דרך גוגל אבל לא למי שרוצה לעקוף כי קל מאד לזייף את ההדר של referrer או למחוק אותו. אבל איך עיברי לידר כתב: ״יותר טוב כמעט מכלום״. (או שהוא כתב ההיפך? לא משנה).

לא לתת לגוגל לסרוק את האתר

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

User-agent: *
Disallow: /files/

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

עוגיות

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

מה שחשוב הוא לוודא שהעוגיה נוצרת עם שלושה פרמטרים:

  1. httpOnly – שרק לצד שרת תהיה גישה אליה, על מנת למנוע התקפות ושנינגנס כללי.
  2. secure – שרק ב-https תהיה גישה אליה.
  3. sameSite: 'Strict' – שהעוגיה לא תעבור בבקשות אלו ואחרות.

על מנת להדגים כמה זה קל – אני אראה שוב עם Node.js ואחר כך גם עם פייתון. מי שלא מכיר את שתי השפות וכן רוצה להבין – שינסה פשוט להתייחס אליהן כפסאודו קוד:

const express = require('express');
const session = require('express-session');

const app = express();
const port = 3000;

// Middleware to create a session cookie
app.use(session({
  secret: 'your-secret-key',
  resave: false,
  saveUninitialized: true,
  cookie: { 
    httpOnly: true, 
    secure: true, 
    sameSite: 'strict' 
  } 
}));

// Middleware to protect /api/folder
app.use('/api/folder', (req, res, next) => {
  if (!req.session.cookieId) {
    req.session.cookieId = req.sessionID;
  }
  if (!req.sessionID) {
    return res.status(403).send('Access denied');
  }
  next();
});

// Serve files from the /api/folder directory
app.use(express.static('/api/folder'));

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

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

from fastapi import FastAPI, Request, Response, HTTPException, Depends
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import FileResponse
import os

app = FastAPI()

# Configure session middleware
app.add_middleware(SessionMiddleware, secret_key="your-secret-key")

# Dependency to verify session
def verify_session(request: Request):
    if not request.session.get('session_id'):
        request.session['session_id'] = "session-{}".format(os.urandom(12).hex())
        return
    if 'session_id' not in request.session:
        raise HTTPException(status_code=403, detail="Access denied")
// Pay attention: This is not a secured code and the file_path should be sanitized.
@app.get("/api/folder/{file_path:path}")
async def get_file(file_path: str, request: Request, session_verified: None = Depends(verify_session)):
    file_location = f"/api/folder/{file_path}"
    if not os.path.exists(file_location):
        raise HTTPException(status_code=404, detail="File not found")
    return FileResponse(file_location)

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

עטיפה של ההורדה בלוגיקה

דרך מעט יותר מסובכת ליישום באתר קיים אבל אפקטיבית מאד היא עטיפת כל הורדה בלוגיקה עסקית עם הגנה שמוודאת שרק מי שלחץ על קישור מתוך האתר מקבל את הקובץ. זה כמובן גם מאט כרייה (ועם הגדרות WAF מתאימות יכול לעצור אותה לחלוטין או לפחות להקשות עליה מאד). זה מסובך רק בגלל שצריך לעטוף כל הורדה בפונקציה שתקרא ל-route מסוים שרק דרכו אפשר להוריד את הקבצים ולא לאפשר הורדה חופשית לעולם. אבל ברוב הפריימוורקים יש הגנת CSRF מובנית, בדיוק כמו ה-session.

הנה דוגמה ב-Node.js שמייצרת גם קליינט. סביר להניח שבאפליקציה אמיתית זה יהיה מסובך יותר אבל בגדול העקרון הוא אותו עקרון. השרת מייצר CSRF token, ושם אותו בצד הלקוח, כשיש הורדה, צד הלקוח שולח את הטוקן והשרת מוודא שזה אותו טוקן חד פעמי. זהו. אפשר ליישם את זה בכמה דרכים.

const express = require('express');
const session = require('express-session');
const csrf = require('csurf');
const path = require('path');
const app = express();
const port = 3000;

// Session configuration
app.use(session({
  secret: 'your-secret-key',
  resave: false,
  saveUninitialized: true
}));

// Enable CSRF protection
const csrfProtection = csrf({ cookie: false });

app.use(express.static('public'));

// Serve the download page with CSRF token
app.get('/download', csrfProtection, (req, res) => {
  res.send(`
    <!DOCTYPE html>
    <html>
    <head>
      <title>Download Page</title>
    </head>
    <body>
      <h1>Download Page</h1>
      <p>Click the button below to download the file.</p>
      <form action="/api/Attachment" method="GET">
        <input type="hidden" name="_csrf" value="${req.csrfToken()}">
        <button type="submit">Download</button>
      </form>
    </body>
    </html>
  `);
});

// Pay attention: this is just a demo, do not use unsanitzed input
// Download route with CSRF validation
app.get('/api/Attachment', csrfProtection, (req, res) => {
  res.download(path.join(__dirname, 'path-to-your-file'));
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}/`);
});

עם Node.js וגם פייתון הדברים האלו קלים יחסית כי יש תשתיות מוכנות, אבל גם בשפות ובפריימוורקים אחרים יש (למשל יש ב-ASP.net core) כי כאמור זו בעיה טריוויאלית שמתרחשת בהרבה מקומות. וכשיש בעיה טריוויאלית, יש לה פתרונות טריוויאליים. אם אתם מנסים לפתור בעיה טריוויאלית באופן לא טריוויאלי או לחלופין – טוענים שאין פתרון – אתם בבעיה.

כמובן שיש עוד דרכים לפתור את הבעיה הזו וייתכן שהדרכים שלעיל לא מושלמות. יש כמה דרכים לממש את הפתרון. אם מישהו או מישהי רוצים לחלוק את הדרך שלהם עבדה – זה יהיה נחמד 🙂

פוסטים נוספים שכדאי לקרוא

פתרונות ומאמרים על פיתוח אינטרנט

יישום של nonce על מנת להגן מפני התקפות injection

בפוסט הקודם הסברתי על hash עם CSP על משאבי inline – שזה נחמד ומעולה אבל פחות ישים בעולם האמיתי שבו בדרך כלל התוכן ה-inline (בין

יסודות בתכנות

מספרים בינאריים בקוד

איך, ויותר חשוב למה, משתמשים במספרים בינאריים בתכנות? גם בפייתון ובג׳אווהסקריפט?

גלילה לראש העמוד