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
This commit is contained in:
hamed
2026-06-20 13:10:17 +03:30
parent a19058d9a2
commit 194ffd889c
29 changed files with 1007 additions and 190 deletions
@@ -0,0 +1,179 @@
# سخت‌سازی امنیتی سایت عمومی: ذخیره‌ی توکن، 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` یا مقدار آن نباشد.