Files
nobat724_front/.claude/prompt/fix-token-storage-xss-headers.md
T
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

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.jsclient_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.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 تغییر نکنند):

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 یا مقدار آن نباشد.