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:
@@ -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` یا مقدار آن نباشد.
|
||||
Reference in New Issue
Block a user