fix(web): XSS in Mapbox popup, add CSP header, CSRF on media upload
- Replace innerHTML/setHTML with DOM API (createElement/textContent/setDOMContent) to prevent XSS via user-controlled listing titles, URLs, and prices - Add Content-Security-Policy header to next.config.js with proper directives for Mapbox, API, images, workers, and frame-ancestors - Add X-CSRF-Token header to media upload fetch call, matching apiClient behavior Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -104,7 +104,10 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa
|
|||||||
const el = document.createElement('button');
|
const el = document.createElement('button');
|
||||||
el.className = 'mapbox-price-marker';
|
el.className = 'mapbox-price-marker';
|
||||||
const isSelected = selectedListingId === marker.listing.id;
|
const isSelected = selectedListingId === marker.listing.id;
|
||||||
el.innerHTML = `<span class="${isSelected ? 'selected' : ''}">${formatPrice(marker.listing.priceVND)}</span>`;
|
const span = document.createElement('span');
|
||||||
|
if (isSelected) span.className = 'selected';
|
||||||
|
span.textContent = formatPrice(marker.listing.priceVND);
|
||||||
|
el.appendChild(span);
|
||||||
el.style.cssText = 'border:none;cursor:pointer;background:none;padding:0;';
|
el.style.cssText = 'border:none;cursor:pointer;background:none;padding:0;';
|
||||||
|
|
||||||
el.addEventListener('click', (e) => {
|
el.addEventListener('click', (e) => {
|
||||||
@@ -129,38 +132,71 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa
|
|||||||
}
|
}
|
||||||
}, [markers, selectedListingId, onMarkerClick]);
|
}, [markers, selectedListingId, onMarkerClick]);
|
||||||
|
|
||||||
|
function buildPopupContent(listing: ListingDetail): HTMLDivElement {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.style.fontFamily = 'system-ui,sans-serif';
|
||||||
|
|
||||||
|
if (listing.property.media.length > 0) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = listing.property.media[0]!.url;
|
||||||
|
img.alt = listing.property.title;
|
||||||
|
img.style.cssText = 'width:100%;height:96px;object-fit:cover;border-radius:6px;margin-bottom:8px;';
|
||||||
|
container.appendChild(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = document.createElement('p');
|
||||||
|
price.style.cssText = 'font-weight:700;color:hsl(142.1,76.2%,36.3%);font-size:14px;margin:0 0 4px;';
|
||||||
|
price.textContent = `${formatPrice(listing.priceVND)} VND`;
|
||||||
|
container.appendChild(price);
|
||||||
|
|
||||||
|
const title = document.createElement('p');
|
||||||
|
title.style.cssText = 'font-size:13px;font-weight:500;margin:0 0 2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
||||||
|
title.textContent = listing.property.title;
|
||||||
|
container.appendChild(title);
|
||||||
|
|
||||||
|
const location = document.createElement('p');
|
||||||
|
location.style.cssText = 'font-size:12px;color:#666;margin:0 0 8px;';
|
||||||
|
location.textContent = `${listing.property.district}, ${listing.property.city}`;
|
||||||
|
container.appendChild(location);
|
||||||
|
|
||||||
|
const details = document.createElement('div');
|
||||||
|
details.style.cssText = 'display:flex;gap:4px;font-size:11px;margin-bottom:8px;';
|
||||||
|
const tagStyle = 'background:#f1f5f9;padding:2px 6px;border-radius:4px;';
|
||||||
|
|
||||||
|
const areaTag = document.createElement('span');
|
||||||
|
areaTag.style.cssText = tagStyle;
|
||||||
|
areaTag.textContent = `${listing.property.areaM2} m\u00B2`;
|
||||||
|
details.appendChild(areaTag);
|
||||||
|
|
||||||
|
if (listing.property.bedrooms != null) {
|
||||||
|
const bedTag = document.createElement('span');
|
||||||
|
bedTag.style.cssText = tagStyle;
|
||||||
|
bedTag.textContent = `${listing.property.bedrooms} PN`;
|
||||||
|
details.appendChild(bedTag);
|
||||||
|
}
|
||||||
|
if (listing.property.bathrooms != null) {
|
||||||
|
const bathTag = document.createElement('span');
|
||||||
|
bathTag.style.cssText = tagStyle;
|
||||||
|
bathTag.textContent = `${listing.property.bathrooms} WC`;
|
||||||
|
details.appendChild(bathTag);
|
||||||
|
}
|
||||||
|
container.appendChild(details);
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = `/listings/${listing.id}`;
|
||||||
|
link.style.cssText = 'display:block;text-align:center;font-size:12px;font-weight:500;color:hsl(142.1,76.2%,36.3%);text-decoration:none;';
|
||||||
|
link.textContent = 'Xem chi ti\u1EBFt \u2192';
|
||||||
|
container.appendChild(link);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
function showPopup(map: mapboxgl.Map, marker: MapMarker) {
|
function showPopup(map: mapboxgl.Map, marker: MapMarker) {
|
||||||
popupRef.current?.remove();
|
popupRef.current?.remove();
|
||||||
|
|
||||||
const { listing } = marker;
|
|
||||||
const imgHtml = listing.property.media.length > 0
|
|
||||||
? `<img src="${listing.property.media[0]!.url}" alt="${listing.property.title}" style="width:100%;height:96px;object-fit:cover;border-radius:6px;margin-bottom:8px;" />`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const popup = new mapboxgl.Popup({ offset: 25, maxWidth: '260px', closeButton: true })
|
const popup = new mapboxgl.Popup({ offset: 25, maxWidth: '260px', closeButton: true })
|
||||||
.setLngLat([marker.lng, marker.lat])
|
.setLngLat([marker.lng, marker.lat])
|
||||||
.setHTML(`
|
.setDOMContent(buildPopupContent(marker.listing))
|
||||||
<div style="font-family:system-ui,sans-serif;">
|
|
||||||
${imgHtml}
|
|
||||||
<p style="font-weight:700;color:hsl(142.1,76.2%,36.3%);font-size:14px;margin:0 0 4px;">
|
|
||||||
${formatPrice(listing.priceVND)} VND
|
|
||||||
</p>
|
|
||||||
<p style="font-size:13px;font-weight:500;margin:0 0 2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
|
||||||
${listing.property.title}
|
|
||||||
</p>
|
|
||||||
<p style="font-size:12px;color:#666;margin:0 0 8px;">
|
|
||||||
${listing.property.district}, ${listing.property.city}
|
|
||||||
</p>
|
|
||||||
<div style="display:flex;gap:4px;font-size:11px;margin-bottom:8px;">
|
|
||||||
<span style="background:#f1f5f9;padding:2px 6px;border-radius:4px;">${listing.property.areaM2} m\u00B2</span>
|
|
||||||
${listing.property.bedrooms != null ? `<span style="background:#f1f5f9;padding:2px 6px;border-radius:4px;">${listing.property.bedrooms} PN</span>` : ''}
|
|
||||||
${listing.property.bathrooms != null ? `<span style="background:#f1f5f9;padding:2px 6px;border-radius:4px;">${listing.property.bathrooms} WC</span>` : ''}
|
|
||||||
</div>
|
|
||||||
<a href="/listings/${listing.id}" style="display:block;text-align:center;font-size:12px;font-weight:500;color:hsl(142.1,76.2%,36.3%);text-decoration:none;">
|
|
||||||
Xem chi tiet →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
`)
|
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
|
|
||||||
popupRef.current = popup;
|
popupRef.current = popup;
|
||||||
|
|||||||
@@ -162,9 +162,19 @@ export const listingsApi = {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
if (caption) formData.append('caption', caption);
|
if (caption) formData.append('caption', caption);
|
||||||
|
|
||||||
|
const csrfToken = typeof document !== 'undefined'
|
||||||
|
? document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/)?.[1]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
if (csrfToken) {
|
||||||
|
headers['X-CSRF-Token'] = decodeURIComponent(csrfToken);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/listings/${listingId}/media`, {
|
const res = await fetch(`${API_BASE_URL}/listings/${listingId}/media`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,22 @@ const nextConfig = {
|
|||||||
{ key: 'X-XSS-Protection', value: '1; mode=block' },
|
{ key: 'X-XSS-Protection', value: '1; mode=block' },
|
||||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(self)' },
|
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(self)' },
|
||||||
|
{
|
||||||
|
key: 'Content-Security-Policy',
|
||||||
|
value: [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://api.mapbox.com",
|
||||||
|
"style-src 'self' 'unsafe-inline' https://api.mapbox.com",
|
||||||
|
"img-src 'self' data: blob: https://*.mapbox.com https://*.tiles.mapbox.com https:",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"connect-src 'self' https://*.mapbox.com https://api.mapbox.com https://events.mapbox.com http://localhost:3001",
|
||||||
|
"worker-src 'self' blob:",
|
||||||
|
"child-src 'self' blob:",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
].join('; '),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user