# سخت‌سازی امنیتی سایت عمومی: ذخیره‌ی توکن، 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 دستی و قابل دور زدن** است (مثلاً `` چون `` در لیست خطرناک نیست عبور می‌کند). → 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 تست `` رندر شود ولی اجرا نشود (DOMPurify پاکش کند). - هدرهای امنیتی در Network tab روی پاسخ صفحات دیده شوند. - در bundle مرورگر (`.next/static`) رشته‌ی `client_secret` یا مقدار آن نباشد.