Files
nobat724_front/.claude/prompt/fix-token-storage-xss-headers.md
hamed 194ffd889c feat: enhance security by implementing HttpOnly refresh tokens and in-memory access token management
- Added isomorphic-dompurify for improved XSS protection
- Refactored token storage to use in-memory management for access tokens
- Implemented server-side route handlers for OAuth token management
- Introduced security headers in next.config.js
- Removed client-side exposure of client_secret and sensitive tokens
- Updated API interceptors to handle token refresh logic
- Cleaned up cookie management for refresh tokens
2026-06-20 13:10:17 +03:30

180 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# سخت‌سازی امنیتی سایت عمومی: ذخیره‌ی توکن، 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` یا مقدار آن نباشد.