001package eu.righettod;
002
003
004import com.auth0.jwt.interfaces.DecodedJWT;
005import org.apache.commons.csv.CSVFormat;
006import org.apache.commons.csv.CSVRecord;
007import org.apache.commons.imaging.ImageInfo;
008import org.apache.commons.imaging.Imaging;
009import org.apache.commons.imaging.common.ImageMetadata;
010import org.apache.commons.validator.routines.EmailValidator;
011import org.apache.commons.validator.routines.InetAddressValidator;
012import org.apache.pdfbox.Loader;
013import org.apache.pdfbox.pdmodel.PDDocument;
014import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
015import org.apache.pdfbox.pdmodel.PDDocumentInformation;
016import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
017import org.apache.pdfbox.pdmodel.common.PDMetadata;
018import org.apache.pdfbox.pdmodel.interactive.action.*;
019import org.apache.pdfbox.pdmodel.interactive.annotation.AnnotationFilter;
020import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
021import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
022import org.apache.poi.poifs.filesystem.DirectoryEntry;
023import org.apache.poi.poifs.filesystem.POIFSFileSystem;
024import org.apache.poi.poifs.macros.VBAMacroReader;
025import org.apache.tika.detect.DefaultDetector;
026import org.apache.tika.detect.Detector;
027import org.apache.tika.io.TemporaryResources;
028import org.apache.tika.io.TikaInputStream;
029import org.apache.tika.metadata.Metadata;
030import org.apache.tika.mime.MediaType;
031import org.apache.tika.mime.MimeTypes;
032import org.w3c.dom.Document;
033import org.xml.sax.EntityResolver;
034import org.xml.sax.InputSource;
035import org.xml.sax.SAXException;
036
037import javax.crypto.Mac;
038import javax.crypto.spec.SecretKeySpec;
039import javax.imageio.ImageIO;
040import javax.json.Json;
041import javax.json.JsonReader;
042import javax.xml.XMLConstants;
043import javax.xml.parsers.DocumentBuilder;
044import javax.xml.parsers.DocumentBuilderFactory;
045import javax.xml.parsers.ParserConfigurationException;
046import javax.xml.stream.XMLInputFactory;
047import javax.xml.stream.XMLStreamReader;
048import javax.xml.stream.events.XMLEvent;
049import javax.xml.validation.Schema;
050import javax.xml.validation.SchemaFactory;
051import java.awt.*;
052import java.awt.image.BufferedImage;
053import java.io.*;
054import java.net.*;
055import java.net.http.HttpClient;
056import java.net.http.HttpRequest;
057import java.net.http.HttpResponse;
058import java.nio.ByteBuffer;
059import java.nio.charset.Charset;
060import java.nio.charset.StandardCharsets;
061import java.nio.file.Files;
062import java.security.MessageDigest;
063import java.security.SecureRandom;
064import java.time.Duration;
065import java.util.*;
066import java.util.List;
067import java.util.concurrent.*;
068import java.util.concurrent.atomic.AtomicInteger;
069import java.util.regex.Pattern;
070import java.util.zip.ZipEntry;
071import java.util.zip.ZipFile;
072
073/**
074 * Provides different utilities methods to apply processing from a security perspective.<br>
075 * These code snippet:
076 * <ul>
077 *     <li>Can be used, as "foundation", to customize the validation to the app context.</li>
078 *     <li>Were implemented in a way to facilitate adding or removal of validations depending on usage context.</li>
079 *     <li>Were centralized on one class to be able to enhance them across time as well as <a href="https://github.com/righettod/code-snippets-security-utils/issues">missing case/bug identification</a>.</li>
080 * </ul>
081 */
082public class SecurityUtils {
083    /**
084     * Default constructor: Not needed as the class only provides static methods.
085     */
086    private SecurityUtils() {
087    }
088
089    /**
090     * Apply a collection of validation to verify if a provided PIN code is considered weak (easy to guess) or none.<br>
091     * This method consider that format of the PIN code is [0-9]{6,}<br>
092     * Rule to consider a PIN code as weak:
093     * <ul>
094     * <li>Length is inferior to 6 positions.</li>
095     * <li>Contain only the same number or only a sequence of zero.</li>
096     * <li>Contain sequence of following incremental or decremental numbers.</li>
097     * </ul>
098     *
099     * @param pinCode PIN code to verify.
100     * @return True only if the PIN is considered as weak.
101     */
102    public static boolean isWeakPINCode(String pinCode) {
103        boolean isWeak = true;
104        //Length is inferior to 6 positions
105        //Use "Long.parseLong(pinCode)" to cause a NumberFormatException if the PIN is not a numeric one
106        //and to ensure that the PIN is not only a sequence of zero
107        if (pinCode != null && Long.parseLong(pinCode) > 0 && pinCode.trim().length() > 5) {
108            //Contain only the same number
109            String regex = String.format("^[%s]{%s}$", pinCode.charAt(0), pinCode.length());
110            if (!Pattern.matches(regex, pinCode)) {
111                //Contain sequence of following incremental or decremental numbers
112                char previousChar = 'X';
113                boolean containSequence = false;
114                for (char c : pinCode.toCharArray()) {
115                    if (previousChar != 'X') {
116                        int previousNbr = Integer.parseInt(String.valueOf(previousChar));
117                        int currentNbr = Integer.parseInt(String.valueOf(c));
118                        if (currentNbr == (previousNbr - 1) || currentNbr == (previousNbr + 1)) {
119                            containSequence = true;
120                            break;
121                        }
122                    }
123                    previousChar = c;
124                }
125                if (!containSequence) {
126                    isWeak = false;
127                }
128            }
129        }
130        return isWeak;
131    }
132
133    /**
134     * Apply a collection of validations on a Word 97-2003 (binary format) document file provided:
135     * <ul>
136     * <li>Real Microsoft Word 97-2003 document file.</li>
137     * <li>No VBA Macro.<br></li>
138     * <li>No embedded objects.</li>
139     * </ul>
140     *
141     * @param wordFilePath Filename of the Word document file to check.
142     * @return True only if the file pass all validations.
143     * @see "https://poi.apache.org/components/"
144     * @see "https://poi.apache.org/components/document/"
145     * @see "https://poi.apache.org/components/poifs/how-to.html"
146     * @see "https://poi.apache.org/components/poifs/embeded.html"
147     * @see "https://poi.apache.org/"
148     * @see "https://mvnrepository.com/artifact/org.apache.poi/poi"
149     */
150    public static boolean isWord972003DocumentSafe(String wordFilePath) {
151        boolean isSafe = false;
152        try {
153            File wordFile = new File(wordFilePath);
154            if (wordFile.exists() && wordFile.canRead() && wordFile.isFile()) {
155                //Step 1: Try to load the file, if its fail then it imply that is not a valid Word 97-2003 format file
156                try (POIFSFileSystem fs = new POIFSFileSystem(wordFile)) {
157                    //Step 2: Check if the document contains VBA macros, in our case is not allowed
158                    VBAMacroReader macroReader = new VBAMacroReader(fs);
159                    Map<String, String> macros = macroReader.readMacros();
160                    if (macros == null || macros.isEmpty()) {
161                        //Step 3: Check if the document contains any embedded objects, in our case is not allowed
162                        //From POI documentation:
163                        //Word normally stores embedded files in subdirectories of the ObjectPool directory, itself a subdirectory of the filesystem root.
164                        //Typically, these subdirectories and named starting with an underscore, followed by 10 numbers.
165                        final List<String> embeddedObjectFound = new ArrayList<>();
166                        DirectoryEntry root = fs.getRoot();
167                        if (root.getEntryCount() > 0) {
168                            root.iterator().forEachRemaining(entry -> {
169                                if ("ObjectPool".equalsIgnoreCase(entry.getName()) && entry instanceof DirectoryEntry) {
170                                    DirectoryEntry objPoolDirectory = (DirectoryEntry) entry;
171                                    if (objPoolDirectory.getEntryCount() > 0) {
172                                        objPoolDirectory.iterator().forEachRemaining(objPoolDirectoryEntry -> {
173                                            if (objPoolDirectoryEntry instanceof DirectoryEntry) {
174                                                DirectoryEntry objPoolDirectoryEntrySubDirectoryEntry = (DirectoryEntry) objPoolDirectoryEntry;
175                                                if (objPoolDirectoryEntrySubDirectoryEntry.getEntryCount() > 0) {
176                                                    objPoolDirectoryEntrySubDirectoryEntry.forEach(objPoolDirectoryEntrySubDirectoryEntryEntry -> {
177                                                        if (objPoolDirectoryEntrySubDirectoryEntryEntry.isDocumentEntry()) {
178                                                            embeddedObjectFound.add(objPoolDirectoryEntrySubDirectoryEntryEntry.getName());
179                                                        }
180                                                    });
181                                                }
182                                            }
183                                        });
184                                    }
185                                }
186                            });
187                        }
188                        isSafe = embeddedObjectFound.isEmpty();
189                    }
190                }
191            }
192        } catch (Exception e) {
193            isSafe = false;
194        }
195        return isSafe;
196    }
197
198    /**
199     * Ensure that an XML file does not contain any External Entity, DTD or XInclude instructions.
200     *
201     * @param xmlFilePath Filename of the XML file to check.
202     * @return True only if the file pass all validations.
203     * @see "https://portswigger.net/web-security/xxe"
204     * @see "https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html#java"
205     * @see "https://docs.oracle.com/en/java/javase/13/security/java-api-xml-processing-jaxp-security-guide.html#GUID-82F8C206-F2DF-4204-9544-F96155B1D258"
206     * @see "https://www.w3.org/TR/xinclude-11/"
207     * @see "https://en.wikipedia.org/wiki/XInclude"
208     */
209    public static boolean isXMLSafe(String xmlFilePath) {
210        boolean isSafe = false;
211        try {
212            File xmlFile = new File(xmlFilePath);
213            if (xmlFile.exists() && xmlFile.canRead() && xmlFile.isFile()) {
214                //Step 1a: Verify that the XML file content does not contain any XInclude instructions
215                boolean containXInclude = Files.readAllLines(xmlFile.toPath()).stream().anyMatch(line -> line.toLowerCase(Locale.ROOT).contains(":include "));
216                if (!containXInclude) {
217                    //Step 1b: Parse the XML file, if an exception occur than it's imply that the XML specified is not a valid ones
218                    //Create an XML document builder throwing Exception if a DOCTYPE instruction is present
219                    DocumentBuilderFactory dbfInstance = DocumentBuilderFactory.newInstance();
220                    dbfInstance.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
221                    //Xerces 2 only
222                    //dbfInstance.setFeature("http://xerces.apache.org/xerces2-j/features.html#disallow-doctype-decl",true);
223                    dbfInstance.setXIncludeAware(false);
224                    DocumentBuilder builder = dbfInstance.newDocumentBuilder();
225                    //Parse the document
226                    Document doc = builder.parse(xmlFile);
227                    isSafe = (doc != null && doc.getDocumentElement() != null);
228                }
229            }
230        } catch (Exception e) {
231            isSafe = false;
232        }
233        return isSafe;
234    }
235
236
237    /**
238     * Extract all URL links from a PDF file provided.<br>
239     * This can be used to apply validation on a PDF against contained links.
240     *
241     * @param pdfFilePath pdfFilePath Filename of the PDF file to process.
242     * @return A List of URL objects that is empty if no links is found.
243     * @throws Exception If any error occurs during the processing of the PDF file.
244     * @see "https://www.gushiciku.cn/pl/21KQ"
245     * @see "https://pdfbox.apache.org/"
246     * @see "https://mvnrepository.com/artifact/org.apache.pdfbox/pdfbox"
247     */
248    public static List<URL> extractAllPDFLinks(String pdfFilePath) throws Exception {
249        final List<URL> links = new ArrayList<>();
250        File pdfFile = new File(pdfFilePath);
251        try (PDDocument document = Loader.loadPDF(pdfFile)) {
252            PDDocumentCatalog documentCatalog = document.getDocumentCatalog();
253            AnnotationFilter actionURIAnnotationFilter = new AnnotationFilter() {
254                @Override
255                public boolean accept(PDAnnotation annotation) {
256                    boolean keep = false;
257                    if (annotation instanceof PDAnnotationLink) {
258                        keep = (((PDAnnotationLink) annotation).getAction() instanceof PDActionURI);
259                    }
260                    return keep;
261                }
262            };
263            documentCatalog.getPages().forEach(page -> {
264                try {
265                    page.getAnnotations(actionURIAnnotationFilter).forEach(annotation -> {
266                        PDActionURI linkAnnotation = (PDActionURI) ((PDAnnotationLink) annotation).getAction();
267                        try {
268                            URL urlObj = new URL(linkAnnotation.getURI());
269                            if (!links.contains(urlObj)) {
270                                links.add(urlObj);
271                            }
272                        } catch (MalformedURLException e) {
273                            throw new RuntimeException(e);
274                        }
275                    });
276                } catch (Exception e) {
277                    throw new RuntimeException(e);
278                }
279            });
280        }
281        return links;
282    }
283
284    /**
285     * Apply a collection of validations on a PDF file provided:
286     * <ul>
287     * <li>Real PDF file.</li>
288     * <li>No attachments.</li>
289     * <li>No Javascript code.</li>
290     * <li>No links using action of type URI/Launch/RemoteGoTo/ImportData.</li>
291     * </ul>
292     *
293     * @param pdfFilePath Filename of the PDF file to check.
294     * @return True only if the file pass all validations.
295     * @see "https://stackoverflow.com/a/36161267"
296     * @see "https://www.gushiciku.cn/pl/21KQ"
297     * @see "https://github.com/jonaslejon/malicious-pdf"
298     * @see "https://pdfbox.apache.org/"
299     * @see "https://mvnrepository.com/artifact/org.apache.pdfbox/pdfbox"
300     */
301    public static boolean isPDFSafe(String pdfFilePath) {
302        boolean isSafe = false;
303        try {
304            File pdfFile = new File(pdfFilePath);
305            if (pdfFile.exists() && pdfFile.canRead() && pdfFile.isFile()) {
306                //Step 1: Try to load the file, if its fail then it imply that is not a valid PDF file
307                try (PDDocument document = Loader.loadPDF(pdfFile)) {
308                    //Step 2: Check if the file contains attached files, in our case is not allowed
309                    PDDocumentCatalog documentCatalog = document.getDocumentCatalog();
310                    PDDocumentNameDictionary namesDictionary = new PDDocumentNameDictionary(documentCatalog);
311                    if (namesDictionary.getEmbeddedFiles() == null) {
312                        //Step 3: Check if the file contains Javascript code, in our case is not allowed
313                        if (namesDictionary.getJavaScript() == null) {
314                            //Step 4: Check if the file contains links using action of type URI/Launch/RemoteGoTo/ImportData, in our case is not allowed
315                            final List<Integer> notAllowedAnnotationCounterList = new ArrayList<>();
316                            AnnotationFilter notAllowedAnnotationFilter = new AnnotationFilter() {
317                                @Override
318                                public boolean accept(PDAnnotation annotation) {
319                                    boolean keep = false;
320                                    if (annotation instanceof PDAnnotationLink) {
321                                        PDAnnotationLink link = (PDAnnotationLink) annotation;
322                                        PDAction action = link.getAction();
323                                        if ((action instanceof PDActionURI) || (action instanceof PDActionLaunch) || (action instanceof PDActionRemoteGoTo) || (action instanceof PDActionImportData)) {
324                                            keep = true;
325                                        }
326                                    }
327                                    return keep;
328                                }
329                            };
330                            documentCatalog.getPages().forEach(page -> {
331                                try {
332                                    notAllowedAnnotationCounterList.add(page.getAnnotations(notAllowedAnnotationFilter).size());
333                                } catch (IOException e) {
334                                    throw new RuntimeException(e);
335                                }
336                            });
337                            if (notAllowedAnnotationCounterList.stream().reduce(0, Integer::sum) == 0) {
338                                isSafe = true;
339                            }
340                        }
341                    }
342                }
343            }
344        } catch (Exception e) {
345            isSafe = false;
346        }
347        return isSafe;
348    }
349
350    /**
351     * Remove as much as possible metadata from the provided PDF document object.
352     *
353     * @param document PDFBox PDF document object on which metadata must be removed.
354     * @see "https://gist.github.com/righettod/d7e07443c43d393a39de741a0d920069"
355     * @see "https://pdfbox.apache.org/"
356     * @see "https://mvnrepository.com/artifact/org.apache.pdfbox/pdfbox"
357     */
358    public static void clearPDFMetadata(PDDocument document) {
359        if (document != null) {
360            PDDocumentInformation infoEmpty = new PDDocumentInformation();
361            document.setDocumentInformation(infoEmpty);
362            PDMetadata newMetadataEmpty = new PDMetadata(document);
363            document.getDocumentCatalog().setMetadata(newMetadataEmpty);
364        }
365    }
366
367
368    /**
369     * Validate that the URL provided is really a relative URL.
370     *
371     * @param targetUrl URL to validate.
372     * @return True only if the file pass all validations.
373     * @see "https://portswigger.net/web-security/ssrf"
374     * @see "https://stackoverflow.com/q/6785442"
375     */
376    public static boolean isRelativeURL(String targetUrl) {
377        boolean isValid = false;
378        //Reject any URL encoded content and URL starting with a double slash
379        //Reject any URL contains credentials or fragment to prevent potential bypasses
380        String work = targetUrl;
381        if (!work.contains("%") && !work.contains("@") && !work.contains("#") && !work.startsWith("//")) {
382            //Creation of a URL object must fail
383            try {
384                new URL(work);
385                isValid = false;
386            } catch (MalformedURLException mf) {
387                //Last check to be sure (for prod usage compile the pattern one time)
388                isValid = Pattern.compile("^/[a-z0-9]+", Pattern.CASE_INSENSITIVE).matcher(work).find();
389            }
390        }
391        return isValid;
392    }
393
394    /**
395     * Apply a collection of validations on a ZIP file provided:
396     * <ul>
397     * <li>Real ZIP file.</li>
398     * <li>Contain less than a specified level of deepness.</li>
399     * <li>Do not contain Zip-Slip entry path.</li>
400     * </ul>
401     *
402     * @param zipFilePath       Filename of the ZIP file to check.
403     * @param maxLevelDeepness  Threshold of deepness above which a ZIP archive will be rejected.
404     * @param rejectArchiveFile Flag to specify if presence of any archive entry will cause the rejection of the ZIP file.
405     * @return True only if the file pass all validations.
406     * @see "https://rules.sonarsource.com/java/type/Security%20Hotspot/RSPEC-5042"
407     * @see "https://security.snyk.io/research/zip-slip-vulnerability"
408     * @see "https://en.wikipedia.org/wiki/Zip_bomb"
409     * @see "https://github.com/ptoomey3/evilarc"
410     * @see "https://github.com/abdulfatir/ZipBomb"
411     * @see "https://www.baeldung.com/cs/zip-bomb"
412     * @see "https://thesecurityvault.com/attacks-with-zip-files-and-mitigations/"
413     * @see "https://wiki.sei.cmu.edu/confluence/display/java/IDS04-J.+Safely+extract+files+from+ZipInputStream"
414     */
415    public static boolean isZIPSafe(String zipFilePath, int maxLevelDeepness, boolean rejectArchiveFile) {
416        List<String> archiveExtensions = Arrays.asList("zip", "tar", "7z", "gz", "jar", "phar", "bz2", "tgz");
417        boolean isSafe = false;
418        try {
419            File zipFile = new File(zipFilePath);
420            if (zipFile.exists() && zipFile.canRead() && zipFile.isFile() && maxLevelDeepness > 0) {
421                //Step 1: Try to load the file, if its fail then it imply that is not a valid ZIP file
422                try (ZipFile zipArch = new ZipFile(zipFile)) {
423                    //Step 2: Parse entries
424                    long deepness = 0;
425                    ZipEntry zipEntry;
426                    String entryExtension;
427                    String zipEntryName;
428                    boolean validationsFailed = false;
429                    Enumeration<? extends ZipEntry> entries = zipArch.entries();
430                    while (entries.hasMoreElements()) {
431                        zipEntry = entries.nextElement();
432                        zipEntryName = zipEntry.getName();
433                        entryExtension = zipEntryName.substring(zipEntryName.lastIndexOf(".") + 1).toLowerCase(Locale.ROOT).trim();
434                        //Step 2a: Check if the current entry is an archive file
435                        if (rejectArchiveFile && archiveExtensions.contains(entryExtension)) {
436                            validationsFailed = true;
437                            break;
438                        }
439                        //Step 2b: Check that level of deepness is inferior to the threshold specified
440                        if (zipEntryName.contains("/")) {
441                            //Determine deepness by inspecting the entry name.
442                            //Indeed, folder will be represented like this: folder/folder/folder/
443                            //So we can count the number of "/" to identify the deepness of the entry
444                            deepness = zipEntryName.chars().filter(ch -> ch == '/').count();
445                            if (deepness > maxLevelDeepness) {
446                                validationsFailed = true;
447                                break;
448                            }
449                        }
450                        //Step 2c: Check if any entries match pattern of zip slip payload
451                        if (zipEntryName.contains("..\\") || zipEntryName.contains("../")) {
452                            validationsFailed = true;
453                            break;
454                        }
455                    }
456                    if (!validationsFailed) {
457                        isSafe = true;
458                    }
459                }
460            }
461        } catch (Exception e) {
462            isSafe = false;
463        }
464        return isSafe;
465    }
466
467    /**
468     * Identify the mime type of the content specified (array of bytes).<br>
469     * Note that it cannot be fully trusted (see the tweet '1595824709186519041' referenced), so, additional validations are required.
470     *
471     * @param content The content as an array of bytes.
472     * @return The mime type in lower case or null if it cannot be identified.
473     * @see "https://twitter.com/righettod/status/1595824709186519041"
474     * @see "https://tika.apache.org/"
475     * @see "https://mvnrepository.com/artifact/org.apache.tika/tika-core"
476     * @see "https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types"
477     * @see "https://www.iana.org/assignments/media-types/media-types.xhtml"
478     */
479    public static String identifyMimeType(byte[] content) {
480        String mimeType = null;
481        if (content != null && content.length > 0) {
482            Detector detector = new DefaultDetector(MimeTypes.getDefaultMimeTypes());
483            Metadata metadata = new Metadata();
484            try {
485                try (TemporaryResources temporaryResources = new TemporaryResources(); TikaInputStream tikaInputStream = TikaInputStream.get(new ByteArrayInputStream(content), temporaryResources, metadata)) {
486                    MediaType mt = detector.detect(tikaInputStream, metadata);
487                    if (mt != null) {
488                        mimeType = mt.toString().toLowerCase(Locale.ROOT);
489                    }
490                }
491            } catch (IOException ioe) {
492                mimeType = null;
493            }
494        }
495        return mimeType;
496    }
497
498    /**
499     * Apply a collection of validations on a string expected to be an public IP address:
500     * <ul>
501     * <li>Is a valid IP v4 or v6 address.</li>
502     * <li>Is public from an Internet perspective.</li>
503     * </ul>
504     * <br>
505     * <b>Note:</b> I often see missing such validation in the value read from HTTP request headers like "X-Forwarded-For" or "Forwarded".
506     * <br><br>
507     * <b>Note for IPv6:</b> I used documentation found so it is really experimental!
508     *
509     * @param ip String expected to be a valid IP address.
510     * @return True only if the string pass all validations.
511     * @see "https://commons.apache.org/proper/commons-validator/"
512     * @see "https://commons.apache.org/proper/commons-validator/apidocs/org/apache/commons/validator/routines/InetAddressValidator.html"
513     * @see "https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html"
514     * @see "https://cheatsheetseries.owasp.org/assets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet_Orange_Tsai_Talk.pdf"
515     * @see "https://cheatsheetseries.owasp.org/assets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet_SSRF_Bible.pdf"
516     * @see "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For"
517     * @see "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded"
518     * @see "https://ipcisco.com/lesson/ipv6-address/"
519     * @see "https://www.juniper.net/documentation/us/en/software/junos/interfaces-security-devices/topics/topic-map/security-interface-ipv4-ipv6-protocol.html"
520     * @see "https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/net/InetAddress.html#getByName(java.lang.String)"
521     * @see "https://www.arin.net/reference/research/statistics/address_filters/"
522     * @see "https://en.wikipedia.org/wiki/Multicast_address"
523     * @see "https://stackoverflow.com/a/5619409"
524     * @see "https://www.ripe.net/media/documents/ipv6-address-types.pdf"
525     * @see "https://www.iana.org/assignments/ipv6-unicast-address-assignments/ipv6-unicast-address-assignments.xhtml"
526     * @see "https://developer.android.com/reference/java/net/Inet6Address"
527     * @see "https://en.wikipedia.org/wiki/Unique_local_address"
528     */
529    public static boolean isPublicIPAddress(String ip) {
530        boolean isValid = false;
531        try {
532            //Quick validation on the string itself based on characters used to compose an IP v4/v6 address
533            if (Pattern.matches("[0-9a-fA-F:.]+", ip)) {
534                //If OK then use the dedicated InetAddressValidator from Apache Commons Validator
535                if (InetAddressValidator.getInstance().isValid(ip)) {
536                    //If OK then validate that is an public IP address
537                    //From Javadoc for "InetAddress.getByName": If a literal IP address is supplied, only the validity of the address format is checked.
538                    InetAddress addr = InetAddress.getByName(ip);
539                    isValid = (!addr.isAnyLocalAddress() && !addr.isLinkLocalAddress() && !addr.isLoopbackAddress() && !addr.isMulticastAddress() && !addr.isSiteLocalAddress());
540                    //If OK and the IP is an V6 one then make additional validation because the built-in Java API will let pass some V6 IP
541                    //For the prefix map, the start of the key indicates if the value is a regex or a string
542                    if (isValid && (addr instanceof Inet6Address)) {
543                        Map<String, String> prefixes = new HashMap<>();
544                        prefixes.put("REGEX_LOOPBACK", "^(0|:)+1$");
545                        prefixes.put("REGEX_UNIQUE-LOCAL-ADDRESSES", "^f(c|d)[a-f0-9]{2}:.*$");
546                        prefixes.put("STRING_LINK-LOCAL-ADDRESSES", "fe80:");
547                        prefixes.put("REGEX_TEREDO", "^2001:[0]*:.*$");
548                        prefixes.put("REGEX_BENCHMARKING", "^2001:[0]*2:.*$");
549                        prefixes.put("REGEX_ORCHID", "^2001:[0]*10:.*$");
550                        prefixes.put("STRING_DOCUMENTATION", "2001:db8:");
551                        prefixes.put("STRING_GLOBAL-UNICAST", "2000:");
552                        prefixes.put("REGEX_MULTICAST", "^ff[0-9]{2}:.*$");
553                        final List<Boolean> results = new ArrayList<>();
554                        final String ipLower = ip.trim().toLowerCase(Locale.ROOT);
555                        prefixes.forEach((addressType, expr) -> {
556                            String exprLower = expr.trim().toLowerCase();
557                            if (addressType.startsWith("STRING_")) {
558                                results.add(ipLower.startsWith(exprLower));
559                            } else {
560                                results.add(Pattern.matches(exprLower, ipLower));
561                            }
562                        });
563                        isValid = ((results.size() == prefixes.size()) && !results.contains(Boolean.TRUE));
564                    }
565                }
566            }
567        } catch (Exception e) {
568            isValid = false;
569        }
570        return isValid;
571    }
572
573    /**
574     * Compute a SHA256 hash from an input composed of a collection of strings.<br><br>
575     * This method take care to build the source string in a way to prevent this source string to be prone to abuse targeting the different parts composing it.<br><br>
576     * <p>
577     * Example of possible abuse without precautions applied during the hash calculation logic:<br>
578     * Hash of <code>SHA256("Hello", "My", "World!!!")</code> will be equals to the hash of <code>SHA256("Hell", "oMyW", "orld!!!")</code>.<br>
579     * </p>
580     * This method ensure that both hash above will be different.<br><br>
581     *
582     * <b>Note:</b> The character <code>|</code> is used, as separator, of every parts so a part is not allowed to contains this character.
583     *
584     * @param parts Ordered list of strings to use to build the input string for which the hash must be computed on. No null value is accepted on object composing the collection.
585     * @return The hash, as an array of bytes, to allow caller to convert it to the final representation wanted (HEX, Base64, etc.). If the collection passed is null or empty then the method return null.
586     * @throws Exception If any exception occurs
587     * @see "https://github.com/righettod/code-snippets-security-utils/issues/16"
588     * @see "https://pentesterlab.com/badges/codereview"
589     * @see "https://blog.trailofbits.com/2024/08/21/yolo-is-not-a-valid-hash-construction/"
590     * @see "https://www.nist.gov/publications/sha-3-derived-functions-cshake-kmac-tuplehash-and-parallelhash"
591     */
592    public static byte[] computeHashNoProneToAbuseOnParts(List<String> parts) throws Exception {
593        byte[] hash = null;
594        String separator = "|";
595        if (parts != null && !parts.isEmpty()) {
596            //Ensure that not part is null
597            if (parts.stream().anyMatch(Objects::isNull)) {
598                throw new IllegalArgumentException("No part must be null!");
599            }
600            //Ensure that the separator is absent from every part
601            if (parts.stream().anyMatch(part -> part.contains(separator))) {
602                throw new IllegalArgumentException(String.format("The character '%s', used as parts separator, must be absent from every parts!", separator));
603            }
604            MessageDigest digest = MessageDigest.getInstance("SHA-256");
605            final StringBuilder buffer = new StringBuilder(separator);
606            parts.forEach(p -> {
607                buffer.append(p).append(separator);
608            });
609            hash = digest.digest(buffer.toString().getBytes(StandardCharsets.UTF_8));
610        }
611        return hash;
612    }
613
614    /**
615     * Ensure that an XML file only uses DTD/XSD references (called System Identifier) present in the allowed list provided.<br><br>
616     * The code is based on the validation implemented into the OpenJDK 21, by the class <b><a href="https://github.com/openjdk/jdk/blob/jdk-21%2B35/src/java.prefs/share/classes/java/util/prefs/XmlSupport.java">java.util.prefs.XmlSupport</a></b>, in the method <b><a href="https://github.com/openjdk/jdk/blob/jdk-21%2B35/src/java.prefs/share/classes/java/util/prefs/XmlSupport.java#L240">loadPrefsDoc()</a></b>.<br><br>
617     * The method also ensure that no Public Identifier is used to prevent potential bypasses of the validations.
618     *
619     * @param xmlFilePath              Filename of the XML file to check.
620     * @param allowedSystemIdentifiers List of URL allowed for System Identifier specified for any XSD/DTD references.
621     * @return True only if the file pass all validations.
622     * @see "https://www.w3schools.com/xml/prop_documenttype_systemid.asp"
623     * @see "https://www.ibm.com/docs/en/integration-bus/9.0.0?topic=doctypedecl-xml-systemid"
624     * @see "https://www.liquid-technologies.com/Reference/Glossary/XML_DocType.html"
625     * @see "https://www.xml.com/pub/98/08/xmlqna0.html"
626     * @see "https://github.com/openjdk/jdk/blob/jdk-21%2B35/src/java.prefs/share/classes/java/util/prefs/XmlSupport.java#L397"
627     * @see "https://en.wikipedia.org/wiki/Formal_Public_Identifier"
628     */
629    public static boolean isXMLOnlyUseAllowedXSDorDTD(String xmlFilePath, final List<String> allowedSystemIdentifiers) {
630        boolean isSafe = false;
631        final String errorTemplate = "Non allowed %s ID detected!";
632        final String emptyFakeDTD = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><!ELEMENT dummy EMPTY>";
633        final String emptyFakeXSD = "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"> <xs:element name=\"dummy\"/></xs:schema>";
634
635        if (allowedSystemIdentifiers == null || allowedSystemIdentifiers.isEmpty()) {
636            throw new IllegalArgumentException("At least one SID must be specified!");
637        }
638        File xmlFile = new File(xmlFilePath);
639        if (xmlFile.exists() && xmlFile.canRead() && xmlFile.isFile()) {
640            try {
641                EntityResolver resolverValidator = (publicId, systemId) -> {
642                    if (publicId != null) {
643                        throw new SAXException(String.format(errorTemplate, "PUBLIC"));
644                    }
645                    if (!allowedSystemIdentifiers.contains(systemId)) {
646                        throw new SAXException(String.format(errorTemplate, "SYSTEM"));
647                    }
648                    //If it is OK then return a empty DTD/XSD
649                    return new InputSource(new StringReader(systemId.toLowerCase().endsWith(".dtd") ? emptyFakeDTD : emptyFakeXSD));
650                };
651                DocumentBuilderFactory dbfInstance = DocumentBuilderFactory.newInstance();
652                dbfInstance.setIgnoringElementContentWhitespace(true);
653                dbfInstance.setXIncludeAware(false);
654                dbfInstance.setValidating(false);
655                dbfInstance.setCoalescing(true);
656                dbfInstance.setIgnoringComments(false);
657                DocumentBuilder builder = dbfInstance.newDocumentBuilder();
658                builder.setEntityResolver(resolverValidator);
659                Document doc = builder.parse(xmlFile);
660                isSafe = (doc != null);
661            } catch (SAXException | IOException | ParserConfigurationException e) {
662                isSafe = false;
663            }
664        }
665
666        return isSafe;
667    }
668
669    /**
670     * Apply a collection of validations on a EXCEL CSV file provided (file was expected to be opened in Microsoft EXCEL):
671     * <ul>
672     * <li>Real CSV file.</li>
673     * <li>Do not contains any payload related to a CSV injections.</li>
674     * </ul>
675     * Ensure that, if Apache Commons CSV does not find any record then, the file will be considered as NOT safe (prevent potential bypasses).<br><br>
676     * <b>Note:</b> Record delimiter used is the <code>,</code> (comma) character. See the Apache Commons CSV reference provided for EXCEL.<br>
677     *
678     * @param csvFilePath Filename of the CSV file to check.
679     * @return True only if the file pass all validations.
680     * @see "https://commons.apache.org/proper/commons-csv/"
681     * @see "https://commons.apache.org/proper/commons-csv/apidocs/org/apache/commons/csv/CSVFormat.html#EXCEL"
682     * @see "https://www.we45.com/post/your-excel-sheets-are-not-safe-heres-how-to-beat-csv-injection"
683     * @see "https://www.whiteoaksecurity.com/blog/2020-4-23-csv-injection-whats-the-risk/"
684     * @see "https://book.hacktricks.xyz/pentesting-web/formula-csv-doc-latex-ghostscript-injection"
685     * @see "https://owasp.org/www-community/attacks/CSV_Injection"
686     * @see "https://payatu.com/blog/csv-injection-basic-to-exploit/"
687     * @see "https://cwe.mitre.org/data/definitions/1236.html"
688     */
689    public static boolean isExcelCSVSafe(String csvFilePath) {
690        boolean isSafe;
691        final AtomicInteger recordCount = new AtomicInteger();
692        final List<Character> payloadDetectionCharacters = List.of('=', '+', '@', '-', '\r', '\t');
693
694        try {
695            final List<String> payloadsIdentified = new ArrayList<>();
696            try (Reader in = new FileReader(csvFilePath)) {
697                Iterable<CSVRecord> records = CSVFormat.EXCEL.parse(in);
698                records.forEach(record -> {
699                    record.forEach(recordValue -> {
700                        if (recordValue != null && !recordValue.trim().isEmpty() && payloadDetectionCharacters.contains(recordValue.trim().charAt(0))) {
701                            payloadsIdentified.add(recordValue);
702                        }
703                        recordCount.getAndIncrement();
704                    });
705                });
706            }
707            isSafe = (payloadsIdentified.isEmpty() && recordCount.get() > 0);
708        } catch (Exception e) {
709            isSafe = false;
710        }
711
712        return isSafe;
713    }
714
715    /**
716     * Provide a way to add an integrity marker (<a href="https://en.wikipedia.org/wiki/HMAC">HMAC</a>) to a serialized object serialized using the <a href="https://www.baeldung.com/java-serialization">java native system</a> (binary).<br>
717     * The goal is to provide <b>a temporary workaround</b> to try to prevent deserialization attacks and give time to move to a text-based serialization approach.
718     *
719     * @param processingMode Define the mode of processing i.e. protect or validate. ({@link eu.righettod.ProcessingMode})
720     * @param input          When the processing mode is "protect" than the expected input (string) is a java serialized object encoded in Base64 otherwise (processing mode is "validate") expected input is the output of this method when the "protect" mode was used.
721     * @param secret         Secret to use to compute the SHA256 HMAC.
722     * @return A map with the following keys: <ul><li><b>PROCESSING_MODE</b>: Processing mode used to compute the result.</li><li><b>STATUS</b>: A boolean indicating if the processing was successful or not.</li><li><b>RESULT</b>: Always contains a string representing the protected serialized object in the format <code>[SERIALIZED_OBJECT_BASE64_ENCODED]:[SERIALIZED_OBJECT_HMAC_BASE64_ENCODED]</code>.</li></ul>
723     * @throws Exception If any exception occurs.
724     * @see "https://cheatsheetseries.owasp.org/cheatsheets/Deserialization_Cheat_Sheet.html"
725     * @see "https://owasp.org/www-project-top-ten/2017/A8_2017-Insecure_Deserialization"
726     * @see "https://portswigger.net/web-security/deserialization"
727     * @see "https://www.baeldung.com/java-serialization-approaches"
728     * @see "https://www.baeldung.com/java-serialization"
729     * @see "https://cryptobook.nakov.com/mac-and-key-derivation/hmac-and-key-derivation"
730     * @see "https://en.wikipedia.org/wiki/HMAC"
731     * @see "https://smattme.com/posts/how-to-generate-hmac-signature-in-java/"
732     */
733    public static Map<String, Object> ensureSerializedObjectIntegrity(ProcessingMode processingMode, String input, byte[] secret) throws Exception {
734        Map<String, Object> results;
735        String resultFormatTemplate = "%s:%s";
736        //Verify input provided to be consistent
737        if (processingMode == null) {
738            throw new IllegalArgumentException("The processing mode is mandatory!");
739        }
740        if (input == null || input.trim().isEmpty()) {
741            throw new IllegalArgumentException("Input data is mandatory!");
742        }
743        if (secret == null || secret.length == 0) {
744            throw new IllegalArgumentException("The HMAC secret is mandatory!");
745        }
746        if (processingMode.equals(ProcessingMode.VALIDATE) && input.split(":").length != 2) {
747            throw new IllegalArgumentException("Input data provided is invalid for the processing mode specified!");
748        }
749        //Processing
750        Base64.Decoder b64Decoder = Base64.getDecoder();
751        Base64.Encoder b64Encoder = Base64.getEncoder();
752        String hmacAlgorithm = "HmacSHA256";
753        Mac mac = Mac.getInstance(hmacAlgorithm);
754        SecretKeySpec key = new SecretKeySpec(secret, hmacAlgorithm);
755        mac.init(key);
756        results = new HashMap<>();
757        results.put("PROCESSING_MODE", processingMode.toString());
758        switch (processingMode) {
759            case PROTECT -> {
760                byte[] objectBytes = b64Decoder.decode(input);
761                byte[] hmac = mac.doFinal(objectBytes);
762                String encodedHmac = b64Encoder.encodeToString(hmac);
763                results.put("STATUS", Boolean.TRUE);
764                results.put("RESULT", String.format(resultFormatTemplate, input, encodedHmac));
765            }
766            case VALIDATE -> {
767                String[] parts = input.split(":");
768                byte[] objectBytes = b64Decoder.decode(parts[0].trim());
769                byte[] hmacProvided = b64Decoder.decode(parts[1].trim());
770                byte[] hmacComputed = mac.doFinal(objectBytes);
771                String encodedHmacComputed = b64Encoder.encodeToString(hmacComputed);
772                Boolean hmacIsValid = Arrays.equals(hmacProvided, hmacComputed);
773                results.put("STATUS", hmacIsValid);
774                results.put("RESULT", String.format(resultFormatTemplate, parts[0].trim(), encodedHmacComputed));
775            }
776            default -> throw new IllegalArgumentException("Not supported processing mode!");
777        }
778        return results;
779    }
780
781    /**
782     * Apply a collection of validations on a JSON string provided:
783     * <ul>
784     * <li>Real JSON structure.</li>
785     * <li>Contain less than a specified number of deepness for nested objects or arrays.</li>
786     * <li>Contain less than a specified number of items in any arrays.</li>
787     * </ul>
788     * <br>
789     * <b>Note:</b> I decided to use a parsing approach using only string processing to prevent any StackOverFlow or OutOfMemory error that can be abused.<br><br>
790     * I used the following assumption:
791     * <ul>
792     *      <li>The character <code>{</code> identify the beginning of an object.</li>
793     *      <li>The character <code>}</code> identify the end of an object.</li>
794     *      <li>The character <code>[</code> identify the beginning of an array.</li>
795     *      <li>The character <code>]</code> identify the end of an array.</li>
796     *      <li>The character <code>"</code> identify the delimiter of a string.</li>
797     *      <li>The character sequence <code>\"</code> identify the escaping of an double quote.</li>
798     * </ul>
799     *
800     * @param json                  String containing the JSON data to validate.
801     * @param maxItemsByArraysCount Maximum number of items allowed in an array.
802     * @param maxDeepnessAllowed    Maximum number nested objects or arrays allowed.
803     * @return True only if the string pass all validations.
804     * @see "https://javaee.github.io/jsonp/"
805     * @see "https://community.f5.com/discussions/technicalforum/disable-buffer-overflow-in-json-parameters/124306"
806     * @see "https://github.com/InductiveComputerScience/pbJson/issues/2"
807     */
808    public static boolean isJSONSafe(String json, int maxItemsByArraysCount, int maxDeepnessAllowed) {
809        boolean isSafe = false;
810
811        try {
812            //Step 1: Analyse the JSON string
813            int currentDeepness = 0;
814            int currentArrayItemsCount = 0;
815            int maxDeepnessReached = 0;
816            int maxArrayItemsCountReached = 0;
817            boolean currentlyInArray = false;
818            boolean currentlyInString = false;
819            int currentNestedArrayLevel = 0;
820            String jsonEscapedDoubleQuote = "\\\"";//Escaped double quote must not be considered as a string delimiter
821            String work = json.replace(jsonEscapedDoubleQuote, "'");
822            for (char c : work.toCharArray()) {
823                switch (c) {
824                    case '{': {
825                        if (!currentlyInString) {
826                            currentDeepness++;
827                        }
828                        break;
829                    }
830                    case '}': {
831                        if (!currentlyInString) {
832                            currentDeepness--;
833                        }
834                        break;
835                    }
836                    case '[': {
837                        if (!currentlyInString) {
838                            currentDeepness++;
839                            if (currentlyInArray) {
840                                currentNestedArrayLevel++;
841                            }
842                            currentlyInArray = true;
843                        }
844                        break;
845                    }
846                    case ']': {
847                        if (!currentlyInString) {
848                            currentDeepness--;
849                            currentArrayItemsCount = 0;
850                            if (currentNestedArrayLevel > 0) {
851                                currentNestedArrayLevel--;
852                            }
853                            if (currentNestedArrayLevel == 0) {
854                                currentlyInArray = false;
855                            }
856                        }
857                        break;
858                    }
859                    case '"': {
860                        currentlyInString = !currentlyInString;
861                        break;
862                    }
863                    case ',': {
864                        if (!currentlyInString && currentlyInArray) {
865                            currentArrayItemsCount++;
866                        }
867                        break;
868                    }
869                }
870                if (currentDeepness > maxDeepnessReached) {
871                    maxDeepnessReached = currentDeepness;
872                }
873                if (currentArrayItemsCount > maxArrayItemsCountReached) {
874                    maxArrayItemsCountReached = currentArrayItemsCount;
875                }
876            }
877            //Step 2: Apply validation against the value specified as limits
878            isSafe = ((maxItemsByArraysCount > maxArrayItemsCountReached) && (maxDeepnessAllowed > maxDeepnessReached));
879
880            //Step 3: If the content is safe then ensure that it is valid JSON structure using the "Java API for JSON Processing" (JSR 374) parser reference implementation.
881            if (isSafe) {
882                JsonReader reader = Json.createReader(new StringReader(json));
883                isSafe = (reader.read() != null);
884            }
885
886        } catch (Exception e) {
887            isSafe = false;
888        }
889        return isSafe;
890    }
891
892    /**
893     * Apply a collection of validations on a image file provided:
894     * <ul>
895     * <li>Real image file.</li>
896     * <li>Its mime type is into the list of allowed mime types.</li>
897     * <li>Its metadata fields do not contains any characters related to a malicious payloads.</li>
898     * </ul>
899     * <br>
900     * <b>Important note:</b> This implementation is prone to bypass using the "<b>raw insertion</b>" method documented in the <a href="https://www.synacktiv.com/en/publications/persistent-php-payloads-in-pngs-how-to-inject-php-code-in-an-image-and-keep-it-there">blog post</a> from the Synacktiv team.
901     * To handle such case, it is recommended to resize the image to remove any non image-related content, see <a href="https://github.com/righettod/document-upload-protection/blob/master/src/main/java/eu/righettod/poc/sanitizer/ImageDocumentSanitizerImpl.java#L54">here</a> for an example.<br>
902     *
903     * @param imageFilePath         Filename of the image file to check.
904     * @param imageAllowedMimeTypes List of image mime types allowed.
905     * @return True only if the file pass all validations.
906     * @see "https://commons.apache.org/proper/commons-imaging/"
907     * @see "https://commons.apache.org/proper/commons-imaging/formatsupport.html"
908     * @see "https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types"
909     * @see "https://www.iana.org/assignments/media-types/media-types.xhtml#image"
910     * @see "https://www.synacktiv.com/en/publications/persistent-php-payloads-in-pngs-how-to-inject-php-code-in-an-image-and-keep-it-there"
911     * @see "https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html"
912     * @see "https://github.com/righettod/document-upload-protection/blob/master/src/main/java/eu/righettod/poc/sanitizer/ImageDocumentSanitizerImpl.java"
913     * @see "https://exiftool.org/examples.html"
914     * @see "https://en.wikipedia.org/wiki/List_of_file_signatures"
915     * @see "https://hexed.it/"
916     * @see "https://github.com/sighook/pixload"
917     */
918    public static boolean isImageSafe(String imageFilePath, List<String> imageAllowedMimeTypes) {
919        boolean isSafe = false;
920        Pattern payloadDetectionRegex = Pattern.compile("[<>${}`]+", Pattern.CASE_INSENSITIVE);
921        try {
922            File imgFile = new File(imageFilePath);
923            if (imgFile.exists() && imgFile.canRead() && imgFile.isFile() && !imageAllowedMimeTypes.isEmpty()) {
924                final byte[] imgBytes = Files.readAllBytes(imgFile.toPath());
925                //Step 1: Check the mime type of the file against the allowed ones
926                ImageInfo imgInfo = Imaging.getImageInfo(imgBytes);
927                if (imageAllowedMimeTypes.contains(imgInfo.getMimeType())) {
928                    //Step 2: Load the image into an object using the Image API
929                    BufferedImage imgObject = Imaging.getBufferedImage(imgBytes);
930                    if (imgObject != null && imgObject.getWidth() > 0 && imgObject.getHeight() > 0) {
931                        //Step 3: Check the metadata if the image format support it - Highly experimental
932                        List<String> metadataWithPayloads = new ArrayList<>();
933                        final ImageMetadata imgMetadata = Imaging.getMetadata(imgBytes);
934                        if (imgMetadata != null) {
935                            imgMetadata.getItems().forEach(item -> {
936                                String metadata = item.toString();
937                                if (payloadDetectionRegex.matcher(metadata).find()) {
938                                    metadataWithPayloads.add(metadata);
939                                }
940                            });
941                        }
942                        isSafe = metadataWithPayloads.isEmpty();
943                    }
944                }
945            }
946        } catch (Exception e) {
947            isSafe = false;
948        }
949        return isSafe;
950    }
951
952    /**
953     * Rewrite the input file to remove any embedded files that is not embedded using a methods supported by the official format of the file.<br>
954     * Example: a file can be embedded by adding it to the end of the source file, see the reference provided for details.
955     *
956     * @param inputFilePath Filename of the file to clean up.
957     * @param inputFileType Type of the file provided.
958     * @return A array of bytes with the cleaned file.
959     * @throws IllegalArgumentException If an invalid parameter is passed
960     * @throws Exception                If any technical error during the cleaning processing
961     * @see "https://www.synacktiv.com/en/publications/persistent-php-payloads-in-pngs-how-to-inject-php-code-in-an-image-and-keep-it-there"
962     * @see "https://github.com/righettod/toolbox-pentest-web/tree/master/misc"
963     * @see "https://github.com/righettod/toolbox-pentest-web?tab=readme-ov-file#misc"
964     * @see "https://stackoverflow.com/a/13605411"
965     */
966    public static byte[] sanitizeFile(String inputFilePath, InputFileType inputFileType) throws Exception {
967        ByteArrayOutputStream sanitizedContent = new ByteArrayOutputStream();
968        File inputFile = new File(inputFilePath);
969        if (!inputFile.exists() || !inputFile.canRead() || !inputFile.isFile()) {
970            throw new IllegalArgumentException("Cannot read the content of the input file!");
971        }
972        switch (inputFileType) {
973            case PDF -> {
974                try (PDDocument document = Loader.loadPDF(inputFile)) {
975                    document.save(sanitizedContent);
976                }
977            }
978            case IMAGE -> {
979                // Load the original image
980                BufferedImage originalImage = ImageIO.read(inputFile);
981                String originalFormat = identifyMimeType(Files.readAllBytes(inputFile.toPath())).split("/")[1].trim();
982                // Check that image has been successfully loaded
983                if (originalImage == null) {
984                    throw new IOException("Cannot load the original image !");
985                }
986                // Get current Width and Height of the image
987                int originalWidth = originalImage.getWidth(null);
988                int originalHeight = originalImage.getHeight(null);
989                // Resize the image by removing 1px on Width and Height
990                Image resizedImage = originalImage.getScaledInstance(originalWidth - 1, originalHeight - 1, Image.SCALE_SMOOTH);
991                // Resize the resized image by adding 1px on Width and Height - In fact set image to is initial size
992                Image initialSizedImage = resizedImage.getScaledInstance(originalWidth, originalHeight, Image.SCALE_SMOOTH);
993                // Save image to a bytes buffer
994                int bufferedImageType = BufferedImage.TYPE_INT_ARGB;//By default use a format supporting transparency
995                if ("jpeg".equalsIgnoreCase(originalFormat) || "bmp".equalsIgnoreCase(originalFormat)) {
996                    bufferedImageType = BufferedImage.TYPE_INT_RGB;
997                }
998                BufferedImage sanitizedImage = new BufferedImage(initialSizedImage.getWidth(null), initialSizedImage.getHeight(null), bufferedImageType);
999                Graphics2D drawer = sanitizedImage.createGraphics();
1000                drawer.drawImage(initialSizedImage, 0, 0, null);
1001                drawer.dispose();
1002                ImageIO.write(sanitizedImage, originalFormat, sanitizedContent);
1003            }
1004            default -> throw new IllegalArgumentException("Type of file not supported !");
1005        }
1006        if (sanitizedContent.size() == 0) {
1007            throw new IOException("An error occur during the rewrite operation!");
1008        }
1009        return sanitizedContent.toByteArray();
1010    }
1011
1012    /**
1013     * Apply a collection of validations on a string expected to be an email address:
1014     * <ul>
1015     * <li>Is a valid email address, from a parser perspective, following RFCs on email addresses.</li>
1016     * <li>Is not using "Encoded-word" format.</li>
1017     * <li>Is not using comment format.</li>
1018     * <li>Is not using "Punycode" format.</li>
1019     * <li>Is not using UUCP style addresses.</li>
1020     * <li>Is not using address literals.</li>
1021     * <li>Is not using source routes.</li>
1022     * <li>Is not using the "percent hack".</li>
1023     * </ul><br>
1024     * This is based on the research work from <a href="https://portswigger.net/research/gareth-heyes">Gareth Heyes</a> added in references (Portswigger).<br><br>
1025     *
1026     * <b>Note:</b> The notion of valid, here, is to take from a secure usage of the data perspective.
1027     *
1028     * @param addr String expected to be a valid email address.
1029     * @return True only if the string pass all validations.
1030     * @see "https://commons.apache.org/proper/commons-validator/"
1031     * @see "https://commons.apache.org/proper/commons-validator/apidocs/org/apache/commons/validator/routines/EmailValidator.html"
1032     * @see "https://datatracker.ietf.org/doc/html/rfc2047#section-2"
1033     * @see "https://portswigger.net/research/splitting-the-email-atom"
1034     * @see "https://www.jochentopf.com/email/address.html"
1035     * @see "https://en.wikipedia.org/wiki/Email_address"
1036     */
1037    public static boolean isEmailAddress(String addr) {
1038        boolean isValid = false;
1039        String work = addr.toLowerCase(Locale.ROOT);
1040        Pattern encodedWordRegex = Pattern.compile("[=?]+", Pattern.CASE_INSENSITIVE);
1041        Pattern forbiddenCharacterRegex = Pattern.compile("[():!%\\[\\],;]+", Pattern.CASE_INSENSITIVE);
1042        try {
1043            //Start with the use of the dedicated EmailValidator from Apache Commons Validator
1044            if (EmailValidator.getInstance(true, true).isValid(work)) {
1045                //If OK then validate it does not contains "Encoded-word" patterns using an aggressive approach
1046                if (!encodedWordRegex.matcher(work).find()) {
1047                    //If OK then validate it does not contains punycode
1048                    if (!work.contains("xn--")) {
1049                        //If OK then validate it does not use:
1050                        // UUCP style addresses,
1051                        // Comment format,
1052                        // Address literals,
1053                        // Source routes,
1054                        // The percent hack.
1055                        if (!forbiddenCharacterRegex.matcher(work).find()) {
1056                            isValid = true;
1057                        }
1058                    }
1059                }
1060            }
1061        } catch (Exception e) {
1062            isValid = false;
1063        }
1064        return isValid;
1065    }
1066
1067    /**
1068     * The <a href="https://www.stet.eu/en/psd2/">PSD2 STET</a> specification require to use <a href="https://datatracker.ietf.org/doc/draft-cavage-http-signatures/">HTTP Signature</a>.
1069     * <br>
1070     * Section <b>3.5.1.2</b> of the document <a href="https://www.stet.eu/assets/files/PSD2/1-6-3/api-dsp2-stet-v1.6.3.1-part-1-framework.pdf">Documentation Framework</a> version <b>1.6.3</b>.
1071     * <br>
1072     * The problem is that, by design, the HTTP Signature specification is prone to blind SSRF.
1073     * <br>
1074     * URL example taken from the STET specification: <code>https://path.to/myQsealCertificate_714f8154ec259ac40b8a9786c9908488b2582b68b17e865fede4636d726b709f</code>.
1075     * <br>
1076     * The objective of this code is to try to decrease the "exploitability/interest" of this SSRF for an attacker.
1077     *
1078     * @param certificateUrl Url pointing to a Qualified Certificate (QSealC) encoded in PEM format and respecting the ETSI/TS119495 technical Specification .
1079     * @return TRUE only if the url point to a Qualified Certificate in PEM format.
1080     * @see "https://www.stet.eu/en/psd2/"
1081     * @see "https://www.stet.eu/assets/files/PSD2/1-6-3/api-dsp2-stet-v1.6.3.1-part-1-framework.pdf"
1082     * @see "https://datatracker.ietf.org/doc/draft-cavage-http-signatures/"
1083     * @see "https://datatracker.ietf.org/doc/rfc9421/"
1084     * @see "https://openjdk.org/groups/net/httpclient/intro.html"
1085     * @see "https://docs.oracle.com/en/java/javase/21/docs/api/java.net.http/java/net/http/package-summary.html"
1086     * @see "https://portswigger.net/web-security/ssrf"
1087     * @see "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control"
1088     */
1089    public static boolean isPSD2StetSafeCertificateURL(String certificateUrl) {
1090        boolean isValid = false;
1091        long connectionTimeoutInSeconds = 10;
1092        String userAgent = "PSD2-STET-HTTPSignature-CertificateRequest";
1093        try {
1094            //1. Ensure that the URL end with the SHA-256 fingerprint encoded in HEX of the certificate like requested by STET
1095            if (certificateUrl != null && certificateUrl.lastIndexOf("_") != -1) {
1096                String digestPart = certificateUrl.substring(certificateUrl.lastIndexOf("_") + 1);
1097                if (Pattern.matches("^[0-9a-f]{64}$", digestPart)) {
1098                    //2. Ensure that the URL is a valid url by creating a instance of the class URI
1099                    URI uri = URI.create(certificateUrl);
1100                    //3. Require usage of HTTPS and reject any url containing query parameters
1101                    if ("https".equalsIgnoreCase(uri.getScheme()) && uri.getQuery() == null) {
1102                        //4. Perform a HTTP HEAD request in order to get the content type of the remote resource
1103                        //and limit the interest to use the SSRF because to pass the check the url need to:
1104                        //- Do not having any query parameters.
1105                        //- Use HTTPS protocol.
1106                        //- End with a string having the format "_[0-9a-f]{64}".
1107                        //- Trigger the malicious action that the attacker want but with a HTTP HEAD without any redirect and parameters.
1108                        HttpResponse<String> response;
1109                        try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build()) {
1110                            HttpRequest request = HttpRequest.newBuilder().uri(uri).timeout(Duration.ofSeconds(connectionTimeoutInSeconds)).method("HEAD", HttpRequest.BodyPublishers.noBody()).header("User-Agent", userAgent)//To provide an hint to the target about the initiator of the request
1111                                    .header("Cache-Control", "no-store, max-age=0")//To prevent caching issues or abuses
1112                                    .build();
1113                            response = client.send(request, HttpResponse.BodyHandlers.ofString());
1114                            if (response.statusCode() == 200) {
1115                                //5. Ensure that the response content type is "text/plain"
1116                                Optional<String> contentType = response.headers().firstValue("Content-Type");
1117                                isValid = (contentType.isPresent() && contentType.get().trim().toLowerCase(Locale.ENGLISH).startsWith("text/plain"));
1118                            }
1119                        }
1120                    }
1121                }
1122            }
1123        } catch (Exception e) {
1124            isValid = false;
1125        }
1126        return isValid;
1127    }
1128
1129    /**
1130     * Perform sequential URL decoding operations against a URL encoded data until the data is not URL encoded anymore or if the specified threshold is reached.
1131     *
1132     * @param encodedData            URL encoded data.
1133     * @param decodingRoundThreshold Threshold above which decoding will fail.
1134     * @return The decoded data.
1135     * @throws SecurityException If the threshold is reached.
1136     * @see "https://en.wikipedia.org/wiki/Percent-encoding"
1137     * @see "https://owasp.org/www-community/Double_Encoding"
1138     * @see "https://portswigger.net/web-security/essential-skills/obfuscating-attacks-using-encodings"
1139     * @see "https://capec.mitre.org/data/definitions/120.html"
1140     */
1141    public static String applyURLDecoding(String encodedData, int decodingRoundThreshold) throws SecurityException {
1142        if (decodingRoundThreshold < 1) {
1143            throw new IllegalArgumentException("Threshold must be a positive number !");
1144        }
1145        if (encodedData == null) {
1146            throw new IllegalArgumentException("Data provided must not be null !");
1147        }
1148        Charset charset = StandardCharsets.UTF_8;
1149        int currentDecodingRound = 0;
1150        boolean isFinished = false;
1151        String currentRoundData = encodedData;
1152        String previousRoundData = encodedData;
1153        while (!isFinished) {
1154            if (currentDecodingRound > decodingRoundThreshold) {
1155                throw new SecurityException(String.format("Decoding round threshold of %s reached!", decodingRoundThreshold));
1156            }
1157            currentRoundData = URLDecoder.decode(currentRoundData, charset);
1158            isFinished = currentRoundData.equals(previousRoundData);
1159            previousRoundData = currentRoundData;
1160            currentDecodingRound++;
1161        }
1162        return currentRoundData;
1163    }
1164
1165    /**
1166     * Apply a collection of validations on a string expected to be an system file/folder path:
1167     * <ul>
1168     * <li>Does not contains path traversal payload.</li>
1169     * <li>The canonical path is equals to the absolute path.</li>
1170     * </ul><br>
1171     *
1172     * @param path String expected to be a valid system file/folder path.
1173     * @return True only if the string pass all validations.
1174     * @see "https://portswigger.net/web-security/file-path-traversal"
1175     * @see "https://learn.snyk.io/lesson/directory-traversal/"
1176     * @see "https://capec.mitre.org/data/definitions/126.html"
1177     * @see "https://owasp.org/www-community/attacks/Path_Traversal"
1178     */
1179    public static boolean isPathSafe(String path) {
1180        boolean isSafe = false;
1181        int decodingRoundThreshold = 3;
1182        try {
1183            if (path != null && !path.isEmpty()) {
1184                //URL decode the path if case of data coming from a web context
1185                String decodedPath = applyURLDecoding(path, decodingRoundThreshold);
1186                //Ensure that no path traversal expression is present
1187                if (!decodedPath.contains("..")) {
1188                    File f = new File(decodedPath);
1189                    String canonicalPath = f.getCanonicalPath();
1190                    String absolutePath = f.getAbsolutePath();
1191                    isSafe = canonicalPath.equals(absolutePath);
1192                }
1193            }
1194        } catch (Exception e) {
1195            isSafe = false;
1196        }
1197        return isSafe;
1198    }
1199
1200    /**
1201     * Identify if an XML contains any XML comments or have any XSL processing instructions.<br>
1202     * Stream reader based parsing is used to support large XML tree.
1203     *
1204     * @param xmlFilePath Filename of the XML file to check.
1205     * @return True only if XML comments or XSL processing instructions are identified.
1206     * @see "https://www.tutorialspoint.com/xml/xml_processing.htm"
1207     * @see "https://docs.oracle.com/en/java/javase/21/docs/api/java.xml/javax/xml/stream/XMLInputFactory.html"
1208     * @see "https://portswigger.net/kb/issues/00400700_xml-entity-expansion"
1209     * @see "https://www.w3.org/Style/styling-XML.en.html"
1210     */
1211    public static boolean isXMLHaveCommentsOrXSLProcessingInstructions(String xmlFilePath) {
1212        boolean itemsDetected = false;
1213        try {
1214            //Ensure that the parser will not be prone XML external entity (XXE) injection or XML entity expansion (XEE) attacks
1215            XMLInputFactory xmlInputFactory = XMLInputFactory.newFactory();
1216            xmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
1217            xmlInputFactory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
1218            xmlInputFactory.setProperty(XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
1219            xmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
1220
1221            //Parse file
1222            try (FileInputStream fis = new FileInputStream(xmlFilePath)) {
1223                XMLStreamReader reader = xmlInputFactory.createXMLStreamReader(fis);
1224                int eventType;
1225                while (reader.hasNext() && !itemsDetected) {
1226                    eventType = reader.next();
1227                    if (eventType == XMLEvent.COMMENT) {
1228                        itemsDetected = true;
1229                    } else if (eventType == XMLEvent.PROCESSING_INSTRUCTION && "xml-stylesheet".equalsIgnoreCase(reader.getPITarget())) {
1230                        itemsDetected = true;
1231                    }
1232                }
1233            }
1234        } catch (Exception e) {
1235            //In case of error then assume that the check failed
1236            itemsDetected = true;
1237        }
1238        return itemsDetected;
1239    }
1240
1241
1242    /**
1243     * Perform a set of additional validations against a JWT token:
1244     * <ul>
1245     *     <li>Do not use the <b>NONE</b> signature algorithm.</li>
1246     *     <li>Have a <a href="https://www.iana.org/assignments/jwt/jwt.xhtml">EXP claim</a> defined.</li>
1247     *     <li>The token identifier (<a href="https://www.iana.org/assignments/jwt/jwt.xhtml">JTI claim</a>) is NOT part of the list of revoked token.</li>
1248     *     <li>Match the expected type of token: ACCESS or ID or REFRESH.</li>
1249     * </ul>
1250     *
1251     * @param token               JWT token for which <b>signature was already validated</b> and on which a set of additional validations will be applied.
1252     * @param expectedTokenType   The type of expected token using the enumeration provided.
1253     * @param revokedTokenJTIList A list of token identifier (<b>JTI</b> claim) referring to tokens that were revoked and to which the JTI claim of the token will be compared to.
1254     * @return True only the token pass all the validations.
1255     * @see "https://www.iana.org/assignments/jwt/jwt.xhtml"
1256     * @see "https://auth0.com/docs/secure/tokens/access-tokens"
1257     * @see "https://auth0.com/docs/secure/tokens/id-tokens"
1258     * @see "https://auth0.com/docs/secure/tokens/refresh-tokens"
1259     * @see "https://auth0.com/blog/id-token-access-token-what-is-the-difference/"
1260     * @see "https://jwt.io/libraries?language=Java"
1261     * @see "https://pentesterlab.com/blog/secure-jwt-library-design"
1262     * @see "https://github.com/auth0/java-jwt"
1263     */
1264    public static boolean applyJWTExtraValidation(DecodedJWT token, TokenType expectedTokenType, List<String> revokedTokenJTIList) {
1265        boolean isValid = false;
1266        TokenType tokenType;
1267        try {
1268            if (!"none".equalsIgnoreCase(token.getAlgorithm().trim())) {
1269                if (!token.getClaim("exp").isMissing() && token.getExpiresAt() != null) {
1270                    String jti = token.getId();
1271                    if (jti != null && !jti.trim().isEmpty()) {
1272                        boolean jtiIsRevoked = revokedTokenJTIList.stream().anyMatch(jti::equalsIgnoreCase);
1273                        if (!jtiIsRevoked) {
1274                            //Determine the token type based on the presence of specifics claims
1275                            if (!token.getClaim("scope").isMissing()) {
1276                                tokenType = TokenType.ACCESS;
1277                            } else if (!token.getClaim("name").isMissing() || !token.getClaim("email").isMissing()) {
1278                                tokenType = TokenType.ID;
1279                            } else {
1280                                tokenType = TokenType.REFRESH;
1281                            }
1282                            isValid = (tokenType.equals(expectedTokenType));
1283                        }
1284                    }
1285                }
1286            }
1287
1288        } catch (Exception e) {
1289            //In case of error then assume that the check failed
1290            isValid = false;
1291        }
1292        return isValid;
1293    }
1294
1295    /**
1296     * Apply a validations on a regular expression to ensure that is not prone to the ReDOS attack.
1297     * <br>If your technology is supported by <a href="https://github.com/doyensec/regexploit">regexploit</a> then <b>use it instead of this method!</b>
1298     * <br>Indeed, the <a href="https://www.doyensec.com/">Doyensec</a> team has made an intensive and amazing work on this topic and created this effective tool.
1299     *
1300     * @param regex                       String expected to be a valid regular expression (regex).
1301     * @param data                        Test data on which the regular expression is executed for the test.
1302     * @param maximumRunningTimeInSeconds Optional parameter to specify a number of seconds above which a regex execution time is considered as not safe (default to 4 seconds when not specified).
1303     * @return True only if the string pass all validations.
1304     * @see "https://github.blog/security/how-to-fix-a-redos/"
1305     * @see "https://learn.snyk.io/lesson/redos"
1306     * @see "https://rules.sonarsource.com/java/RSPEC-2631/"
1307     * @see "https://github.com/doyensec/regexploit"
1308     * @see "https://wiki.owasp.org/images/2/23/OWASP_IL_2009_ReDoS.pdf"
1309     * @see "https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS"
1310     */
1311    public static boolean isRegexSafe(String regex, String data, Optional<Integer> maximumRunningTimeInSeconds) {
1312        Objects.requireNonNull(maximumRunningTimeInSeconds, "Use 'Optional.empty()' to leverage the default value.");
1313        Objects.requireNonNull(data, "A sample data is needed to perform the test.");
1314        Objects.requireNonNull(regex, "A regular expression is needed to perform the test.");
1315        boolean isSafe = false;
1316        int executionTimeout = maximumRunningTimeInSeconds.orElse(4);
1317        ExecutorService executor = Executors.newSingleThreadExecutor();
1318        try {
1319            Callable<Boolean> task = () -> {
1320                Pattern pattern = Pattern.compile(regex);
1321                return pattern.matcher(data).matches();
1322            };
1323            List<Future<Boolean>> tasks = executor.invokeAll(List.of(task), executionTimeout, TimeUnit.SECONDS);
1324            if (!tasks.getFirst().isCancelled()) {
1325                isSafe = true;
1326            }
1327        } catch (Exception e) {
1328            isSafe = false;
1329        } finally {
1330            executor.shutdownNow();
1331        }
1332        return isSafe;
1333    }
1334
1335    /**
1336     * Compute a UUID version 7 without using any external dependency.<br><br>
1337     * <b>Below are my personal point of view and perhaps I'm totally wrong!</b>
1338     * <br><br>
1339     * Why such method?
1340     * <ul>
1341     * <li>Java inferior or equals to 21 does not supports natively the generation of an UUID version 7.</li>
1342     * <li>Import a library just to generate such value is overkill for me.</li>
1343     * <li>Library that I have found, generating such version of an UUID, are not provided by entities commonly used in the java world, such as the SPRING framework provider.</li>
1344     * </ul>
1345     * <br>
1346     * <b>Full credits for this implementation goes to the authors and contributors of the <a href="https://github.com/nalgeon/uuidv7">UUIDv7</a> project.</b>
1347     * <br><br>
1348     * Below are the java libraries that I have found but, for which, I do not trust enough the provider to use them directly:
1349     * <ul>
1350     *     <li><a href="https://github.com/cowtowncoder/java-uuid-generator">java-uuid-generator</a></li>
1351     *     <li><a href="https://github.com/f4b6a3/uuid-creator">uuid-creator</a></li>
1352     * </ul>
1353     *
1354     * @return A UUID object representing the UUID v7.
1355     * @see "https://uuid7.com/"
1356     * @see "https://antonz.org/uuidv7/"
1357     * @see "https://mccue.dev/pages/3-11-25-life-altering-postgresql-patterns"
1358     * @see "https://www.ietf.org/archive/id/draft-peabody-dispatch-new-uuid-format-04.html#name-uuid-version-7"
1359     * @see "https://www.baeldung.com/java-generating-time-based-uuids"
1360     * @see "https://en.wikipedia.org/wiki/Universally_unique_identifier"
1361     * @see "https://buildkite.com/resources/blog/goodbye-integers-hello-uuids/"
1362     */
1363    public static UUID computeUUIDv7() {
1364        SecureRandom secureRandom = new SecureRandom();
1365        // Generate truly random bytes
1366        byte[] value = new byte[16];
1367        secureRandom.nextBytes(value);
1368        // Get current timestamp in milliseconds
1369        ByteBuffer timestamp = ByteBuffer.allocate(Long.BYTES);
1370        timestamp.putLong(System.currentTimeMillis());
1371        // Create the TIMESTAMP part of the UUID
1372        System.arraycopy(timestamp.array(), 2, value, 0, 6);
1373        // Create the VERSION and the VARIANT parts of the UUID
1374        value[6] = (byte) ((value[6] & 0x0F) | 0x70);
1375        value[8] = (byte) ((value[8] & 0x3F) | 0x80);
1376        //Create the HIGH and LOW parts of the UUID
1377        ByteBuffer buf = ByteBuffer.wrap(value);
1378        long high = buf.getLong();
1379        long low = buf.getLong();
1380        //Create and return the UUID object
1381        UUID uuidv7 = new UUID(high, low);
1382        return uuidv7;
1383    }
1384
1385    /**
1386     * Ensure that an XSD file does not contain any include/import/redefine instruction (prevent exposure to SSRF).
1387     *
1388     * @param xsdFilePath Filename of the XSD file to check.
1389     * @return True only if the file pass all validations.
1390     * @see "https://portswigger.net/web-security/ssrf"
1391     * @see "https://www.w3schools.com/Xml/el_import.asp"
1392     * @see "https://www.w3schools.com/xml/el_include.asp"
1393     * @see "https://www.linkedin.com/posts/righettod_appsec-appsecurity-java-activity-7344048434326188053-6Ru9"
1394     * @see "https://docs.oracle.com/en/java/javase/21/docs/api/java.xml/javax/xml/validation/SchemaFactory.html#setProperty(java.lang.String,java.lang.Object)"
1395     */
1396    public static boolean isXSDSafe(String xsdFilePath) {
1397        boolean isSafe = false;
1398        try {
1399            File xsdFile = new File(xsdFilePath);
1400            if (xsdFile.exists() && xsdFile.canRead() && xsdFile.isFile()) {
1401                //Parse the XSD file, if an exception occur then it's imply that the XSD specified is not a valid ones
1402                //Create an schema factory throwing Exception if a external schema is specified
1403                SchemaFactory schemaFactory = SchemaFactory.newDefaultInstance();
1404                schemaFactory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
1405                schemaFactory.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
1406                //Parse the schema
1407                Schema schema = schemaFactory.newSchema(xsdFile);
1408                isSafe = (schema != null);
1409            }
1410        } catch (Exception e) {
1411            isSafe = false;
1412        }
1413        return isSafe;
1414    }
1415}