- 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
11 KiB
سختسازی امنیتی سایت عمومی: ذخیرهی توکن، 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 در درخواست:
getToken: (grant_type, client_id, client_secret, uuid, code, scope = "nobat724") => {
// ...
formData.append("client_secret", client_secret);
// ...
}
components/register/verificationPage/SendReq.js (و مشابه در SubmitData.js):
Cookies.set("access_token", response.access_token, cookieOptions);
Cookies.set("refresh_token", response.refresh_token, { expires: 7 });
services/api.js interceptor:
if (config.requireAuth) {
const token = Cookies.get("access_token");
if (token) config.headers.Authorization = `Bearer ${token}`;
}
lib/sanitize.js — regex قابل دور زدن:
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).
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.jsinterceptor بهجای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 تغییر نکنند):
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 اضافه کن:
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یا مقدار آن نباشد.