mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
fix(kml-import): address PR #488 review issues
- Strip BOM (U+FEFF) from 14 translation files injected by editor - Guard KMZ unpack against zip-bomb: check entry.uncompressedSize against 50 MB cap (KMZ_DECOMPRESSED_SIZE_LIMIT) before calling .buffer(); limit is an exported constant so tests can override it - Fix non-BMP HTML entity decoding: replace String.fromCharCode with String.fromCodePoint + 0x10FFFF bounds check so emoji like 😀 round-trip correctly - Switch KML namespace stripping from regex to fast-xml-parser's removeNSPrefix option; XMLValidator accepts namespaced XML natively, making the pre-strip step unnecessary - Remove dead skippedCount overwrite after transaction; per-loop increment already tracks it alongside per-item error messages - Type multer req.file as Express.Multer.File on both /import/gpx and /import/map routes instead of (req as any).file - Add unit tests: emoji entity decoding (decimal + hex), KMZ zip-bomb rejection, KMZ-with-no-KML rejection
This commit is contained in:
@@ -50,22 +50,14 @@ function decodeHtmlEntities(value: string): string {
|
||||
return withNamedEntities
|
||||
.replace(/&#(\d+);/g, (_, dec) => {
|
||||
const code = Number(dec);
|
||||
return Number.isFinite(code) ? String.fromCharCode(code) : _;
|
||||
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : _;
|
||||
})
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
|
||||
const code = Number.parseInt(hex, 16);
|
||||
return Number.isFinite(code) ? String.fromCharCode(code) : _;
|
||||
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : _;
|
||||
});
|
||||
}
|
||||
|
||||
export function stripXmlNamespaces(xml: string): string {
|
||||
// KML exports vary heavily; stripping namespace declarations/prefixes makes parsing resilient.
|
||||
return xml
|
||||
.replace(/\sxmlns(:\w+)?="[^"]*"/g, '')
|
||||
.replace(/\sxmlns(:\w+)?='[^']*'/g, '')
|
||||
.replace(/<(\/?)\w+:/g, '<$1');
|
||||
}
|
||||
|
||||
export function decodeUtf8WithWarning(fileBuffer: Buffer): { text: string; warning: string | null } {
|
||||
try {
|
||||
return { text: UTF8_DECODER_FATAL.decode(fileBuffer), warning: null };
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
extractKmlPlacemarkNodes,
|
||||
parsePlacemarkNode,
|
||||
resolveCategoryIdForFolder,
|
||||
stripXmlNamespaces,
|
||||
type KmlImportSummary,
|
||||
} from './kmlImport';
|
||||
|
||||
@@ -254,9 +253,12 @@ const gpxParser = new XMLParser({
|
||||
const kmlParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
removeNSPrefix: true,
|
||||
isArray: (name) => ['Placemark', 'Folder', 'Document'].includes(name),
|
||||
});
|
||||
|
||||
export const KMZ_DECOMPRESSED_SIZE_LIMIT = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
export function importGpx(tripId: string, fileBuffer: Buffer) {
|
||||
const parsed = gpxParser.parse(fileBuffer.toString('utf-8'));
|
||||
const gpx = parsed?.gpx;
|
||||
@@ -327,14 +329,13 @@ export function importGpx(tripId: string, fileBuffer: Buffer) {
|
||||
|
||||
export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImportResult {
|
||||
const decoded = decodeUtf8WithWarning(fileBuffer);
|
||||
const xmlWithoutNamespaces = stripXmlNamespaces(decoded.text);
|
||||
|
||||
const validationResult = XMLValidator.validate(xmlWithoutNamespaces);
|
||||
const validationResult = XMLValidator.validate(decoded.text);
|
||||
if (validationResult !== true) {
|
||||
throw new Error('Malformed KML: invalid XML structure');
|
||||
}
|
||||
|
||||
const parsed = kmlParser.parse(xmlWithoutNamespaces);
|
||||
const parsed = kmlParser.parse(decoded.text);
|
||||
const kmlRoot = parsed?.kml ?? parsed;
|
||||
|
||||
if (!kmlRoot || typeof kmlRoot !== 'object') {
|
||||
@@ -391,7 +392,6 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport
|
||||
});
|
||||
|
||||
insertAll();
|
||||
summary.skippedCount = summary.totalPlacemarks - summary.createdCount;
|
||||
|
||||
if (summary.totalPlacemarks === 0) {
|
||||
summary.errors.push('No Placemarks found in KML file.');
|
||||
@@ -400,7 +400,10 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport
|
||||
return { places: created, count: created.length, summary };
|
||||
}
|
||||
|
||||
export async function unpackKmzToKml(kmzBuffer: Buffer): Promise<Buffer> {
|
||||
export async function unpackKmzToKml(
|
||||
kmzBuffer: Buffer,
|
||||
decompressedSizeLimit = KMZ_DECOMPRESSED_SIZE_LIMIT,
|
||||
): Promise<Buffer> {
|
||||
let zip;
|
||||
try {
|
||||
zip = await unzipper.Open.buffer(kmzBuffer);
|
||||
@@ -414,6 +417,11 @@ export async function unpackKmzToKml(kmzBuffer: Buffer): Promise<Buffer> {
|
||||
}
|
||||
|
||||
const preferredEntry = kmlEntries.find((entry) => entry.path.toLowerCase().endsWith('doc.kml')) || kmlEntries[0];
|
||||
|
||||
if (preferredEntry.uncompressedSize > decompressedSizeLimit) {
|
||||
throw new Error('KMZ archive exceeds the maximum allowed decompressed size.');
|
||||
}
|
||||
|
||||
return preferredEntry.buffer();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user