# سخت‌سازی امنیتی سایت عمومی: ذخیره‌ی توکن، XSS، هدرها، client_secret

## پروژه

`nobat724_front` (سایت عمومی). این پرامپت **cross-repo** است — پرامپت همتای backend: `clinicpro/.claude/prompt/fix-auth-token-hardening.md` که **اول** باید اجرا شود (قرارداد توکن را عوض می‌کند: `oauth/token` به‌جای `uuid` فیلد `grant` می‌گیرد و `verify-code` فیلد `grant` برمی‌گرداند).

مرجع: گزارش امنیتی این session (OWASP Top 10). یافته‌های frontend: **C-2 (Critical)**، **C-3 (Critical)**، **H-2 (High)**، **H-3 (High)**.

## زمینه

ممیزی امنیتی این مشکلات را در سایت عمومی پیدا کرد:

- **C-2:** `access_token` و `refresh_token` با `js-cookie` ست می‌شوند (`Cookies.set(...)`) — یعنی **غیر HttpOnly، بدون Secure، بدون SameSite**؛ هر اسکریپتی می‌تواند بخواندشان. با XSS → سرقت کامل توکن.
- **C-3:** بدنه‌ی بلاگ/کلینیک/پزشک با `dangerouslySetInnerHTML={{ __html: sanitizeHtml(...) }}` رندر می‌شود، اما `lib/sanitize.js` یک sanitizer **regex دستی و قابل دور زدن** است (مثلاً `<img src=x onerror=...>` چون `<img>` در لیست خطرناک نیست عبور می‌کند). → Stored XSS.
- **H-2:** `next.config.js` هیچ تابع `headers()` ندارد → بدون CSP/HSTS/X-Frame-Options/nosniff/Referrer-Policy/Permissions-Policy.
- **H-3:** توکن با `NEXT_PUBLIC_CLIENT_SECRET` گرفته می‌شود؛ هر مقدار `NEXT_PUBLIC_*` داخل bundle مرورگر inline و عمومی می‌شود.

## مشکل / هدف

refresh token را به کوکی **HttpOnly سمت سرور** ببر و توکن‌گیری/OAuth را در یک Route Handler سرور-ساید انجام بده (تا `client_secret` و `refresh_token` هرگز به مرورگر نروند)؛ sanitizer را با **DOMPurify** عوض کن؛ security headers را در `next.config.js` اضافه کن.

## فایل‌های مرتبط

| فایل | نقش |
|------|-----|
| `services/api.js` | interceptor — خواندن `access_token` از کوکی |
| `services/response.js` | `getToken` که `client_secret` می‌فرستد |
| `components/register/verificationPage/SendReq.js` | ست‌کردن کوکی‌ها بعد از لاگین (`Cookies.set`) |
| `components/appointment/detail/SubmitData.js` | `Cookies.set("access_token"/"refresh_token")` |
| `components/dashboard/userAccount/.../ButtonSendData.js` | `Cookies.set("access_token")` |
| `lib/sanitize.js` | sanitizer regex فعلی |
| `lib/req.js` / `services/clinicApi.js` | فراخوانی‌های مصرف‌کننده‌ی توکن جدید |
| `app/api/auth/token/route.js` (جدید) | Route Handler سرور-ساید برای OAuth + ست کوکی HttpOnly |
| `next.config.js` | افزودن `headers()` |

## وضعیت فعلی (کد واقعی)

`services/response.js` — `client_secret` در درخواست:
```js
getToken: (grant_type, client_id, client_secret, uuid, code, scope = "nobat724") => {
  // ...
  formData.append("client_secret", client_secret);
  // ...
}
```

`components/register/verificationPage/SendReq.js` (و مشابه در SubmitData.js):
```js
Cookies.set("access_token", response.access_token, cookieOptions);
Cookies.set("refresh_token", response.refresh_token, { expires: 7 });
```

`services/api.js` interceptor:
```js
if (config.requireAuth) {
  const token = Cookies.get("access_token");
  if (token) config.headers.Authorization = `Bearer ${token}`;
}
```

`lib/sanitize.js` — regex قابل دور زدن:
```js
const DANGEROUS_TAGS = ['script','iframe','object','embed','link','meta','base','form'];
export function sanitizeHtml(html) {
  let sanitized = html;
  DANGEROUS_TAGS.forEach((tag) => { /* regex replace */ });
  sanitized = sanitized.replace(/\s+on\w+\s*=\s*.../gi, '');
  return sanitized;
}
```

`next.config.js` — بدون `headers()`.

## وظایف

### ۱. OAuth + ست کوکی در یک Route Handler سرور-ساید (C-2, H-3)

یک Route Handler بساز: `app/api/auth/token/route.js`. این هندلر:
- ورودی `{ grant }` (مطابق قرارداد جدید backend) را از body می‌گیرد.
- توکن را با `process.env.CLIENT_SECRET` (**بدون** `NEXT_PUBLIC`) و `process.env.CLIENT_ID` از backend (`oauth/token`) می‌گیرد.
- `refresh_token` را در کوکی **HttpOnly; Secure; SameSite=Lax; path=/** ست می‌کند.
- `access_token` را در پاسخ JSON برمی‌گرداند تا کلاینت در **memory** نگهش دارد (نه کوکی، نه localStorage).

```js
import { cookies } from 'next/headers';
import axios from 'axios';

export async function POST(req) {
  const { grant } = await req.json();
  const res = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/oauth/token`, {
    grant_type: 'mobile',
    grant,
    client_id: process.env.CLIENT_ID,
    client_secret: process.env.CLIENT_SECRET,
  });
  const { access_token, refresh_token, expires_in } = res.data;
  const jar = cookies();
  jar.set('refresh_token', refresh_token, {
    httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30,
  });
  return Response.json({ access_token, expires_in });
}
```

یک Route Handler refresh هم بساز (`app/api/auth/refresh/route.js`) که `refresh_token` را از کوکی HttpOnly می‌خواند، `oauth/token/refresh` را صدا می‌زند، کوکی جدید ست می‌کند و access token تازه برمی‌گرداند. و `app/api/auth/logout/route.js` که کوکی را پاک و `oauth/logout` را صدا می‌زند.

> `services/response.js::getToken` که `client_secret` سمت کلاینت می‌فرستد را **حذف/جایگزین** کن با فراخوانی این Route Handler. هیچ `NEXT_PUBLIC_CLIENT_SECRET` در کد کلاینت نماند.

### ۲. مدیریت access token در memory به‌جای کوکی JS (C-2)

- همه‌ی `Cookies.set("access_token", ...)` و `Cookies.set("refresh_token", ...)` را حذف کن (SendReq.js, SubmitData.js, ButtonSendData.js و هرجای دیگر).
- access token را در یک ماژول in-memory نگه‌دار (مثلاً `lib/tokenStore.js` با یک متغیر و getter/setter، یا context). در `services/api.js` interceptor به‌جای `Cookies.get("access_token")` از این store بخوان.
- روی 401، interceptor اول `app/api/auth/refresh` را امتحان کند؛ اگر شکست خورد، logout و redirect.
- چون access token در memory با refresh صفحه پاک می‌شود، در bootstrap اپ (مثلاً یک Provider بالای درخت) یک‌بار `app/api/auth/refresh` صدا بزن تا از روی کوکی HttpOnly، access token تازه بگیری.

> کوکی‌های `uuid`/`userInfo` که حساس نیستند می‌توانند بمانند ولی `userInfo` را به فیلدهای غیرحساس محدود کن.

### ۳. جایگزینی sanitizer با DOMPurify (C-3)

`isomorphic-dompurify` را نصب و `lib/sanitize.js` را بازنویسی کن (امضای `sanitizeHtml` حفظ شود تا `Caption.js`/`TextDetail.js`/`blog`/`clinic`/`doctor` تغییر نکنند):
```js
import DOMPurify from 'isomorphic-dompurify';

export function sanitizeHtml(html) {
  if (!html || typeof html !== 'string') return '';
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p','br','strong','em','b','i','u','ul','ol','li','a','h2','h3','h4','blockquote','img','span','table','thead','tbody','tr','td','th'],
    ALLOWED_ATTR: ['href','target','rel','src','alt','title'],
    ALLOW_DATA_ATTR: false,
  });
}
// safeJsonParse را همان‌طور که هست نگه دار
```
> `isomorphic-dompurify` در SSR (Server Component) و کلاینت هر دو کار می‌کند — مهم، چون این صفحات SSR هستند.

### ۴. Security headers در next.config.js (H-2)

تابع `headers()` به `nextConfig` اضافه کن:
```js
async headers() {
  return [{
    source: '/(.*)',
    headers: [
      { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
      { key: 'X-Frame-Options', value: 'DENY' },
      { key: 'X-Content-Type-Options', value: 'nosniff' },
      { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
      { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
      { key: 'Content-Security-Policy', value: [
          "default-src 'self'",
          "img-src 'self' https: data:",
          "script-src 'self' 'unsafe-inline'",   // اگر JSON-LD/Next نیاز داشت؛ در صورت امکان nonce
          "style-src 'self' 'unsafe-inline'",
          "font-src 'self' data:",
          "connect-src 'self' https://api.clinic-pro.ir",
          "frame-ancestors 'none'",
          "object-src 'none'",
          "base-uri 'self'",
        ].join('; ') },
    ],
  }];
},
```
> CSP را با build و مرور صفحات اصلی (home/doctor/clinic/blog/panel) تست کن؛ اگر چیزی بلاک شد (MUI inline style, JSON-LD)، با `'unsafe-inline'` فقط برای style یا nonce برای script حلش کن — نه باز کردن کامل.

## نکات مهم

- **ترتیب:** اول پرامپت backend اجرا شود (قرارداد `grant`)، بعد این. تا قبل از آن، `oauth/token` همچنان `uuid` می‌خواهد؛ این پرامپت بر اساس قرارداد جدید (`grant`) نوشته شده.
- App Router؛ Route Handlerها سرور-ساید هستند و به `process.env.CLIENT_SECRET` دسترسی دارند بدون افشا به مرورگر.
- multi-domain را نشکن: `domain` کوکی را مثل کد فعلی بر اساس hostname ست کن (برای کوکی HttpOnly در Route Handler هم همان منطق domain را اعمال کن تا روی ساب‌دامین‌های شهرها کار کند).
- بعد از تغییر env، مطمئن شو `CLIENT_SECRET` و `CLIENT_ID` (بدون `NEXT_PUBLIC`) در محیط deploy ست شده‌اند (در `docker-compose.yml` از قبل `CLIENT_SECRET`/`CLIENT_ID` تعریف شده).
- تست:
  - `npm run build` بدون خطا.
  - جریان لاگین کامل: OTP → access token در memory، refresh در کوکی HttpOnly (در DevTools → Application → Cookies باید `HttpOnly` ✓ و `Secure` ✓ باشد).
  - refresh صفحه → اپ از کوکی HttpOnly دوباره access token می‌گیرد و کاربر لاگین می‌ماند.
  - در DevTools Console: `document.cookie` نباید `access_token`/`refresh_token` نشان دهد.
  - بدنه‌ی بلاگ با payload تست `<img src=x onerror=alert(1)>` رندر شود ولی اجرا نشود (DOMPurify پاکش کند).
  - هدرهای امنیتی در Network tab روی پاسخ صفحات دیده شوند.
  - در bundle مرورگر (`.next/static`) رشته‌ی `client_secret` یا مقدار آن نباشد.
