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');
|
||||
el.className = 'mapbox-price-marker';
|
||||
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.addEventListener('click', (e) => {
|
||||
@@ -129,38 +132,71 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa
|
||||
}
|
||||
}, [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) {
|
||||
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 })
|
||||
.setLngLat([marker.lng, marker.lat])
|
||||
.setHTML(`
|
||||
<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>
|
||||
`)
|
||||
.setDOMContent(buildPopupContent(marker.listing))
|
||||
.addTo(map);
|
||||
|
||||
popupRef.current = popup;
|
||||
|
||||
@@ -162,9 +162,19 @@ export const listingsApi = {
|
||||
formData.append('file', file);
|
||||
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`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,22 @@ const nextConfig = {
|
||||
{ key: 'X-XSS-Protection', value: '1; mode=block' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ 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