אבטחת קלט ופלט באפליקציה

אפליקציה שנבנתה עם AI עובדת, ולכן קל לפספס שהמסד פתוח לכל אחד, שמשתמש יכול לשנות לעצמו את המסלול, או שמפתח סודי יושב בקוד שבדפדפן. ה-Skill מלמד את Claude לבדוק ולתקן כל נקודת קלט ופלט: escape, קבצים שמועלים, שרת שלא מאמין לבקשה, הרשאות במסד, webhooks וסודות, ומגיע עם סקריפטים לסריקה ולבדיקת התחזות. זהו Skill ל-Claude: תיקייה עם קובץ SKILL.md, שנטענת אוטומטית כשהמשימה מתאימה לתיאור שבראש הקובץ. בניגוד לפרומפט שמדביקים בכל פעם מחדש, Skill מותקן פעם אחת וממשיך לעבוד לבד.

קוד ואיכות · אבטחה · Supabase · RLS · webhook · סודות

מתי הוא נכנס לפעולה

---
name: input-output-security
description: "Protect every input and output point of a web app: HTML and URL escaping, file upload checks, a server that does not trust the request body, Supabase RLS and SECURITY DEFINER rules, webhook verification, provider errors that do not leak, secret scanning, and impersonation tests. Use when building or auditing forms, uploads, edge functions, webhooks, admin screens or payment and credit logic."
---

# אבטחת קלט ופלט

נבנה ונבדק בפרויקט אמיתי (<DOMAIN>), על Vite + React + Supabase + Vercel.
העקרונות כלליים. הקוד ב-`reference/` הוא הגרסה שעלתה לאוויר, אחרי ניקוי,
והכלים ב-`scripts/` נבדקו מול הפרויקט, כולל ביקורת שלילית (סוד שנשתל נתפס).

## העיקרון שמעל הכול

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

## 1. קלט בדפדפן

**`reference/escape.ts`**:
- `escapeHtml` לכל טקסט שנכנס ל-HTML שנבנה כמחרוזת (מיילים, קבצי HTML
  שמורידים, תבניות). React מגן רק בתוך JSX.
- `safeUrl` עם **רשימת היתר** (`https:`, `http:`, `mailto:`, `tel:`, נתיב
  יחסי, `#`). `javascript:` ו-`data:` נחסמים גם בפורמטים עקומים.
- `jsonForScript` ל-JSON שנכנס לתוך `<script>` (למשל JSON-LD): מקודד **כל**
  `<` ו-`>` לרצף יוניקוד, ולכן `</script>` בתוך שם עסק לא יכול לסגור את
  התגית. מקודד גם U+2028/U+2029, ששוברים JS. הערך שנקרא בצד השני לא משתנה.
- `escapeJs` למחרוזת שנכנסת לקוד JS שנוצר, ו-`digitsOnly` למספרי טלפון.
- **U+2028 בתוך regex literal נשבר כשכותבים את הקובץ עם כלים.** להשתמש ב-
  `String.fromCharCode(0x2028)` ובפיצול, כמו בקובץ.

**`reference/upload-guard.ts` + `Dropzone.tsx`**:
- סוג לפי `accept`, **גודל מקסימלי**, מספר קבצים, והודעת דחייה בעברית.
- `rejectionMessage` חסין לקלט פגום. פאזינג מצא שם קריסה.
- **סוג הקובץ בדפדפן הוא נוחות.** מה שנשלח לשרת נבדק שוב בשרת.

## 2. השרת לא מאמין לגוף הבקשה

**פונקציית התראות (`reference/functions/notify-never-trust-body.ts`):**
מקבלת **מזהה בלבד**, שואלת את המסד בהרשאת שרת מה לשלוח, ושולחת רק אם הרשומה
חדשה ולא טופלה (החותמת נכתבת באותה שאילתה). קורא זר יכול לכל היותר לגרום
למייל אחד על אירוע אמיתי שהיה נשלח בכל מקרה. זה מה שמאפשר לה להיות פתוחה בלי
מפתח, כשטריגר ב-DB קורא לה.

**שגיאת ספק נרשמת בלוג ולא נשלחת.** גוף השגיאה של ספק AI נושא את שם המודל
ואת מבנה ה-API. ללקוח: "השירות עמוס" / "העיבוד נכשל".

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

## 3. זהות

**מזהה משתמש שנשלח בגוף חייב להיות של הקורא:**
```sql
if v_subject like 'u:%' and (auth.uid() is null or v_subject <> 'u:' || auth.uid()::text) then
  v_subject := '';  -- נספר במונים, לא נשמר כהיסטוריה של אדם אחר
end if;
```
בלי זה, אנונימי שותל בהיסטוריה של משתמש כלים שלא פתח, ומסך הניהול מציג
את זה כעובדה. `reference/sql/bind_identity_and_admin_functions.sql`.

**פונקציה "שלי" (`my_*`, `cancel_my_*`) לפי `auth.uid()`, לעולם לא לפי פרמטר.**

## 4. הרשאות Supabase

| כלל | למה |
|---|---|
| **RLS דלוק על כל טבלה.** טבלה בלי policy = חסומה לכולם חוץ מ-service_role | זה המצב הבטוח. ה-advisor מסמן INFO, וזה בסדר |
| **`security definer` תמיד עם `set search_path`** | אחרת אפשר להחליף פונקציה בסכמה אחרת |
| **`revoke ... from public, anon` ואז `grant` מפורש** | ברירת המחדל של Postgres נותנת execute ל-public |
| **פונקציות כסף (ניכוי, החזר, קבלות), `service_role` בלבד** | פונקציית החזר שנגישה מהדפדפן היא מכונה להדפסת קרדיטים |
| **כל `admin_*` בודקת `is_admin()`** | בדיקה בשרת. הקישור במסך הוא נוחות |
| **עדיף `raise exception 'forbidden'` על תשובה ריקה** | ריק נראה כמו "אין נתונים", וזה מבלבל גם בדיקות |

### הגנה על עמודות לפי תפקיד, לא לפי דגל

**החור שנמצא:** טריגר שחסם שינוי של `plan` אלא אם GUC מסוים דלוק. משתמש יכול
היה להדליק את ה-GUC בעצמו (`set_config`) ולשדרג את עצמו.

```sql
create or replace function public.protect_plan_columns()
returns trigger language plpgsql security invoker set search_path = public as $
begin
  -- פונקציית SECURITY DEFINER רצה כבעלים, ולכן current_user שם אינו anon.
  -- משתמש שמעדכן ישירות דרך ה-API רץ כ-authenticated, ונחסם
  if current_user not in ('anon', 'authenticated') then return new; end if;
  if new.plan is distinct from old.plan or new.role is distinct from old.role then
    raise exception 'plan and role can only be changed by the server';
  end if;
  return new;
end $;
```
`reference/sql/protect_columns_by_role.sql`.

## 5. Webhooks וכסף

`reference/functions/billing-webhook.ts` (הזרימה) ו-
`reference/functions/webhook-verify-server-to-server.ts` (האימות מול הספק):
- **כל תשלום מאומת מול הספק** (`getTransactionInfo` וכדומה) לפני הענקה.
  ההודעה עצמה היא עדות בלבד.
- **כשהמפתחות לא מוגדרים, נכשל סגור** (`503 not_configured`), לא "מאשר ברירת מחדל".
- **מפתח ייחודי על (ספק, מזהה עסקה)**, כי ספקים שולחים אותה הודעה פעמיים.
- **סכום מאומת מול המסלול**, לא נלקח מההודעה.

בדיקה: לשלוח webhook מזויף אחד ולוודא שהמסלול של המשתמש לא השתנה.

## 6. תהליך ביקורת, לפי הסדר

1. **`get_advisors` (security)** של Supabase. בפרויקט משותף, להפריד מה שייך
   לאפליקציה הזו. לבדוק לעומק כל פונקציה ש-anon יכול להריץ: מה היא קוראת,
   מה היא כותבת, ומה היא מחזירה.
2. **סודות:** `node scripts/scan-secrets.mjs dist .`, בנדל, מאגר, היסטוריה,
   source maps, ו-role של כל JWT בבנדל. יוצא עם 1 אם נמצא.
3. **אנונימי:** `node scripts/probe-anon.mjs --prefix admin_`, כל פונקציות
   הניהול מול מפתח anon. **רק פונקציות קריאה.**
4. **התחזות:** `scripts/rls-impersonation-test.sql`, משתמש רגיל, ביקורת
   חיובית של מנהל, התקפות (שדרוג עצמי, תפקיד, דגל), וזיוף זהות. הכול
   מתגלגל לאחור.
5. **webhook מזויף** לפונקציית התשלום.
6. **הבטחות מול מימוש** (בסקיל `israeli-legal-pack`).

## מלכודות שנפלנו בהן בבדיקות עצמן

- **`perform` על פונקציה שמחזירה אפס שורות לא זורק.** בדיקה שסימנה "החזירה
  נתונים ← חור" על `admin_users` היתה שגויה; ספירה ישירה הראתה 0.
- **`lastIndexOf('function public.x(')` נוחת על שורת ה-grant**, לא על ההגדרה.
  לעגן על `create (or replace )?function`.
- **`npx tsc --noEmit` בפרויקט Vite עם references בודק כלום** (`"files": []`).
  להריץ `tsc -p tsconfig.app.json --noEmit`, או לסמוך על `npm run build`.
- **עוגן במוטציה שלא נמצא משאיר את הקובץ כמו שהוא**, ונראה בדיוק כמו בדיקה
  שלא תפסה. לוודא שהקובץ השתנה לפני שמסיקים.
- **`success: true` ממיגרציה אינו אימות.** לבדוק שהאובייקטים קיימים,
  ההרשאות נכונות, והטריגרים דלוקים.
- **בכלים: backslash וגרשיים נבלעים** ב-heredoc, ב-`node -e` ובפקודות shell.
  לכתוב סקריפט לקובץ ולהריץ אותו.

## בדיקות שכדאי שיהיו בחבילת הבדיקות

- פונקציות ההחזר/הכסף חסומות ל-anon ול-authenticated (regex על המיגרציות).
- כל `admin_*` מכילה `is_admin()`.
- `log_tool_event` (או מקבילה) קושרת את המזהה ל-`auth.uid()`.
- הטריגר שמגן על עמודות לא קורא דגל שהמשתמש יכול לקבוע.
- שגיאת ספק לא נשלחת ללקוח (`console.error` בלבד).
- **ואחרי כל אחת: מוטציה.** פותחים את החור זמנית ומוודאים שהבדיקה נופלת.

איך מתקינים

  1. צרו תיקייה בשם input-output-security בתוך ‎.claude/skills/‎ בפרויקט, או בתיקיית הבית לשימוש בכל הפרויקטים.
  2. שמרו בתוכה את הקובץ בשם SKILL.md.
  3. זהו. אין צורך להפעיל כלום, והוא ייטען לבד כשהמשימה תתאים לתיאור.