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:
Ho Ngoc Hai
2026-04-08 13:08:10 +07:00
parent 91b76d567b
commit 585fdc6ab6
3 changed files with 90 additions and 28 deletions

View File

@@ -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 &rarr;
</a>
</div>
`)
.setDOMContent(buildPopupContent(marker.listing))
.addTo(map);
popupRef.current = popup;

View File

@@ -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,
});

View File

@@ -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('; '),
},
],
},
];