fix: resolve critical bugs and security issues across the project
Security: - Disable SSL verification only in development (lib/req.js) - Wrap all JSON.parse(cookie) calls in try-catch via safeJsonParse utility - Sanitize dangerouslySetInnerHTML in blog/clinic with sanitizeHtml utility - Fix open redirect in payment page — validate URL origin before redirect - Fix cookie cleanup on 401 — use js-cookie with correct domain scope Performance: - Wrap ItemDoctor with React.memo to prevent unnecessary re-renders - Replace <img> with Next.js <Image> in blog Caption component Functionality: - Fix memory leak in Recode.js — store intervals in refs, cleanup on unmount - Add null guard on retryIcon.current before classList manipulation - Fix getParsedUserInfo in helper to handle malformed cookie gracefully Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
1aa9f82d2a
commit
59e0a0fe4f
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Button } from "@mui/material";
|
import { Button } from "@mui/material";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -109,4 +110,4 @@ function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ItemDoctor;
|
export default memo(ItemDoctor);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getUser } from "@/lib/auth";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { fetchReq } from "@/lib/req";
|
import { fetchReq } from "@/lib/req";
|
||||||
|
import { safeJsonParse } from "@/lib/sanitize";
|
||||||
|
|
||||||
async function LayoutPanel({ children }) {
|
async function LayoutPanel({ children }) {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
@@ -22,7 +23,8 @@ async function LayoutPanel({ children }) {
|
|||||||
const accessToken = cookieStore.get("access_token");
|
const accessToken = cookieStore.get("access_token");
|
||||||
|
|
||||||
if (userInfo && accessToken) {
|
if (userInfo && accessToken) {
|
||||||
const parsedUserInfo = JSON.parse(userInfo.value);
|
const parsedUserInfo = safeJsonParse(userInfo.value);
|
||||||
|
if (!parsedUserInfo) return;
|
||||||
const representationUuid = parsedUserInfo?.representation_uuid;
|
const representationUuid = parsedUserInfo?.representation_uuid;
|
||||||
|
|
||||||
if (representationUuid) {
|
if (representationUuid) {
|
||||||
|
|||||||
@@ -199,8 +199,13 @@ export default function PaymentDetailsPage() {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const paymentUrl = `${process.env.NEXT_PUBLIC_API_URL}/payment/${payment.uuid}`;
|
try {
|
||||||
window.location.href = paymentUrl;
|
const apiOrigin = new URL(process.env.NEXT_PUBLIC_API_URL).origin;
|
||||||
|
const paymentUrl = new URL(`/payment/${payment.uuid}`, apiOrigin);
|
||||||
|
if (paymentUrl.origin === apiOrigin) {
|
||||||
|
window.location.href = paymentUrl.href;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
}}
|
}}
|
||||||
className="flex-1 bg-[#5559CE] hover:bg-[#4448b3] text-white font-bold py-3 px-6 rounded-lg transition-colors"
|
className="flex-1 bg-[#5559CE] hover:bg-[#4448b3] text-white font-bold py-3 px-6 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { request } from "@/services/response";
|
import { request } from "@/services/response";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import Container from "./Container";
|
import Container from "./Container";
|
||||||
|
import { safeJsonParse } from "@/lib/sanitize";
|
||||||
|
|
||||||
const defaultData = {
|
const defaultData = {
|
||||||
phone: { value: "", isEdit: false },
|
phone: { value: "", isEdit: false },
|
||||||
@@ -39,7 +40,7 @@ function AppointmentPage({ doctor, disabledDates, matchedCity }) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const userInfo = Cookies.get("userInfo");
|
const userInfo = Cookies.get("userInfo");
|
||||||
const parsedData = userInfo && JSON.parse(userInfo);
|
const parsedData = safeJsonParse(userInfo);
|
||||||
const usernameFromCookie = parsedData?.username || "";
|
const usernameFromCookie = parsedData?.username || "";
|
||||||
|
|
||||||
// ابتدا شماره موبایل را از Cookie ست میکنیم
|
// ابتدا شماره موبایل را از Cookie ست میکنیم
|
||||||
@@ -112,7 +113,7 @@ function AppointmentPage({ doctor, disabledDates, matchedCity }) {
|
|||||||
const refetchUserData = async () => {
|
const refetchUserData = async () => {
|
||||||
if (step === 3) {
|
if (step === 3) {
|
||||||
const userInfo = Cookies.get("userInfo");
|
const userInfo = Cookies.get("userInfo");
|
||||||
const parsedData = userInfo && JSON.parse(userInfo);
|
const parsedData = safeJsonParse(userInfo);
|
||||||
const usernameFromCookie = parsedData?.username || "";
|
const usernameFromCookie = parsedData?.username || "";
|
||||||
|
|
||||||
if (userInfo && parsedData && !data.uuid) {
|
if (userInfo && parsedData && !data.uuid) {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { sanitizeHtml } from "@/lib/sanitize";
|
||||||
|
|
||||||
function Caption({ data }) {
|
function Caption({ data }) {
|
||||||
const imageUrl = data?.images?.[0]?.url;
|
const imageUrl = data?.images?.[0]?.url;
|
||||||
@@ -6,16 +8,19 @@ function Caption({ data }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{imageUrl && imageUrl.trim() !== "" && (
|
{imageUrl && imageUrl.trim() !== "" && (
|
||||||
<img
|
<div className="relative w-full aspect-video rounded-[8px] overflow-hidden">
|
||||||
className="rounded-[8px] overflow-hidden w-full object-cover"
|
<Image
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={data?.title || "blog cover"}
|
alt={data?.title || "blog cover"}
|
||||||
/>
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{data?.body?.value && (
|
{data?.body?.value && (
|
||||||
<div
|
<div
|
||||||
className="my-[12px] sm:my-[16px] md:my-[20px] lg:my-[24px] text-[#525252] text-[14px] md:text-[15px] lg:text-[16px] font-normal leading-[26px] sm:leading-[28px] md:leading-[30px] lg:leading-[32px]"
|
className="my-[12px] sm:my-[16px] md:my-[20px] lg:my-[24px] text-[#525252] text-[14px] md:text-[15px] lg:text-[16px] font-normal leading-[26px] sm:leading-[28px] md:leading-[30px] lg:leading-[32px]"
|
||||||
dangerouslySetInnerHTML={{ __html: data.body.value }}
|
dangerouslySetInnerHTML={{ __html: sanitizeHtml(data.body.value) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@mui/material";
|
import { Button } from "@mui/material";
|
||||||
import MultilineLoading from "@/app/component/loading/Multiline";
|
import MultilineLoading from "@/app/component/loading/Multiline";
|
||||||
|
import { sanitizeHtml } from "@/lib/sanitize";
|
||||||
|
|
||||||
function TextDetail({ data }) {
|
function TextDetail({ data }) {
|
||||||
const [isMore, setIsMore] = useState(false);
|
const [isMore, setIsMore] = useState(false);
|
||||||
@@ -20,7 +21,7 @@ function TextDetail({ data }) {
|
|||||||
"after:bg-[linear-gradient(transparent,#FAFAFA)] max-h-[200px] after:absolute"
|
"after:bg-[linear-gradient(transparent,#FAFAFA)] max-h-[200px] after:absolute"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
dangerouslySetInnerHTML={{ __html: data?.caption }}
|
dangerouslySetInnerHTML={{ __html: sanitizeHtml(data?.caption) }}
|
||||||
></p>
|
></p>
|
||||||
</MultilineLoading>
|
</MultilineLoading>
|
||||||
{data?.caption?.length > 180 && (
|
{data?.caption?.length > 180 && (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import List from "./List";
|
|||||||
import Head from "./Head";
|
import Head from "./Head";
|
||||||
import { request } from "@/services/response";
|
import { request } from "@/services/response";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
|
import { safeJsonParse } from "@/lib/sanitize";
|
||||||
|
|
||||||
function Transactions({ user, loading }) {
|
function Transactions({ user, loading }) {
|
||||||
const [payments, setPayments] = useState([]);
|
const [payments, setPayments] = useState([]);
|
||||||
@@ -23,7 +24,8 @@ function Transactions({ user, loading }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedData = JSON.parse(userInfo);
|
const parsedData = safeJsonParse(userInfo);
|
||||||
|
if (!parsedData) { setIsLoading(false); return; }
|
||||||
const userId = parsedData.id || parsedData.uuid;
|
const userId = parsedData.id || parsedData.uuid;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
|
|||||||
@@ -2,45 +2,66 @@ import RetryLogin from "@/components/icons/RetryLogin";
|
|||||||
import { changeNumToDefault } from "@/helper";
|
import { changeNumToDefault } from "@/helper";
|
||||||
import { request } from "@/services/response";
|
import { request } from "@/services/response";
|
||||||
import { Button } from "@mui/material";
|
import { Button } from "@mui/material";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
const time_resend_code = 120;
|
const time_resend_code = 120;
|
||||||
|
|
||||||
function Recode({ retryIcon, timer, setUuid, num, setTimer }) {
|
function Recode({ retryIcon, timer, setUuid, num, setTimer }) {
|
||||||
|
const timerIntervalRef = useRef(null);
|
||||||
|
const animIntervalRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
|
||||||
|
if (animIntervalRef.current) clearInterval(animIntervalRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleTimer = () => {
|
const handleTimer = () => {
|
||||||
setTimer("loading");
|
setTimer("loading");
|
||||||
|
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setTimer(time_resend_code);
|
setTimer(time_resend_code);
|
||||||
let timePresent = time_resend_code;
|
let timePresent = time_resend_code;
|
||||||
const timerInterval = setInterval(() => {
|
timerIntervalRef.current = setInterval(() => {
|
||||||
timePresent--;
|
timePresent--;
|
||||||
setTimer(timePresent);
|
setTimer(timePresent);
|
||||||
|
if (!timePresent) {
|
||||||
!timePresent && clearInterval(timerInterval);
|
clearInterval(timerIntervalRef.current);
|
||||||
|
timerIntervalRef.current = null;
|
||||||
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rotateIcon = () => {
|
const rotateIcon = () => {
|
||||||
|
if (!retryIcon.current) return;
|
||||||
retryIcon.current.classList.add("retry-icon");
|
retryIcon.current.classList.add("retry-icon");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
retryIcon.current && retryIcon.current.classList.remove("retry-icon");
|
retryIcon.current?.classList.remove("retry-icon");
|
||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReq = () => {
|
const handleReq = () => {
|
||||||
rotateIcon();
|
rotateIcon();
|
||||||
const interval = setInterval(() => {
|
if (animIntervalRef.current) clearInterval(animIntervalRef.current);
|
||||||
|
animIntervalRef.current = setInterval(() => {
|
||||||
rotateIcon();
|
rotateIcon();
|
||||||
}, 1200);
|
}, 1200);
|
||||||
request
|
request
|
||||||
.sendCode(changeNumToDefault(num), "")
|
.sendCode(changeNumToDefault(num), "")
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
clearInterval(interval);
|
clearInterval(animIntervalRef.current);
|
||||||
|
animIntervalRef.current = null;
|
||||||
if (response.uuid) {
|
if (response.uuid) {
|
||||||
setUuid(response.uuid);
|
setUuid(response.uuid);
|
||||||
handleTimer();
|
handleTimer();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => clearInterval(interval));
|
.catch(() => {
|
||||||
|
clearInterval(animIntervalRef.current);
|
||||||
|
animIntervalRef.current = null;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,7 +70,6 @@ function Recode({ retryIcon, timer, setUuid, num, setTimer }) {
|
|||||||
disabled={typeof timer === "number" && timer !== 0}
|
disabled={typeof timer === "number" && timer !== 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!timer && timer !== "loading") {
|
if (!timer && timer !== "loading") {
|
||||||
// handleTimer();
|
|
||||||
handleReq();
|
handleReq();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
+6
-2
@@ -217,8 +217,12 @@ export function hasDataChanged(previousData, newData) {
|
|||||||
|
|
||||||
export const getParsedUserInfo = () => {
|
export const getParsedUserInfo = () => {
|
||||||
const user = Cookies.get("userInfo");
|
const user = Cookies.get("userInfo");
|
||||||
const parsedUserInfo = user && JSON.parse(user);
|
if (!user) return null;
|
||||||
return parsedUserInfo;
|
try {
|
||||||
|
return JSON.parse(user);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleTimeExpiresToken = (expires_in) => {
|
export const handleTimeExpiresToken = (expires_in) => {
|
||||||
|
|||||||
+4
-3
@@ -1,4 +1,5 @@
|
|||||||
import { AbilityBuilder, createMongoAbility } from "@casl/ability";
|
import { AbilityBuilder, createMongoAbility } from "@casl/ability";
|
||||||
|
import { safeJsonParse } from "./sanitize";
|
||||||
|
|
||||||
export function defineAbilitiesFor(user) {
|
export function defineAbilitiesFor(user) {
|
||||||
const { can, cannot, build } = new AbilityBuilder(createMongoAbility);
|
const { can, cannot, build } = new AbilityBuilder(createMongoAbility);
|
||||||
@@ -6,9 +7,9 @@ export function defineAbilitiesFor(user) {
|
|||||||
if (user) {
|
if (user) {
|
||||||
can("access", "Dashboard");
|
can("access", "Dashboard");
|
||||||
cannot("access", "Login");
|
cannot("access", "Login");
|
||||||
const parsedData = JSON.parse(user.value);
|
const parsedData = safeJsonParse(user.value);
|
||||||
|
const roles = parsedData?.roles;
|
||||||
if (parsedData.roles && Object.values(parsedData.roles).includes("representation")) {
|
if (roles && Array.isArray(Object.values(roles)) && Object.values(roles).includes("representation")) {
|
||||||
can("access", "Panel");
|
can("access", "Panel");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+3
-4
@@ -1,11 +1,10 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
|
|
||||||
// Create axios instance with SSL verification disabled for development
|
|
||||||
const axiosInstance = axios.create({
|
const axiosInstance = axios.create({
|
||||||
httpsAgent: new https.Agent({
|
...(process.env.NODE_ENV === "development" && {
|
||||||
rejectUnauthorized: false
|
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||||
})
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fetchReq = async (url, headers) => {
|
export const fetchReq = async (url, headers) => {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const DANGEROUS_TAGS = ['script', 'iframe', 'object', 'embed', 'link', 'meta', 'base', 'form'];
|
||||||
|
|
||||||
|
export function sanitizeHtml(html) {
|
||||||
|
if (!html || typeof html !== 'string') return '';
|
||||||
|
|
||||||
|
let sanitized = html;
|
||||||
|
|
||||||
|
DANGEROUS_TAGS.forEach((tag) => {
|
||||||
|
const openClose = new RegExp(`<${tag}[\\s\\S]*?(?:<\\/${tag}>|/?>)`, 'gi');
|
||||||
|
sanitized = sanitized.replace(openClose, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
sanitized = sanitized.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
|
||||||
|
sanitized = sanitized.replace(/(?:javascript|vbscript):/gi, '');
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeJsonParse(str, fallback = null) {
|
||||||
|
if (!str) return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(str);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-2
@@ -1,6 +1,5 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import "react-toastify/dist/ReactToastify.css";
|
|
||||||
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
|
const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
@@ -35,8 +34,13 @@ api.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error?.response?.status === 401 && typeof window !== "undefined") {
|
if (error?.response?.status === 401 && typeof window !== "undefined") {
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const domain = hostname.includes("localhost")
|
||||||
|
? undefined
|
||||||
|
: `.${hostname.split(".").slice(-2).join(".")}`;
|
||||||
|
const opts = { path: "/", ...(domain && { domain }) };
|
||||||
["access_token", "refresh_token", "uuid", "userInfo"].forEach((key) => {
|
["access_token", "refresh_token", "uuid", "userInfo"].forEach((key) => {
|
||||||
document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
Cookies.remove(key, opts);
|
||||||
});
|
});
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user