Skip to content

Xml SBOM parsing not thread safe #626

Open
@Lajcik

Description

@Lajcik

Parsing xml sboms is not done in a thread-safe manner. When running parsing concurrently in multiple threads there is a chance to randomly crash with an exception like this:

Caused by: com.fasterxml.jackson.databind.JsonMappingException: For input string: "20242024202420242024" (through reference chain: org.cyclonedx.model.Bom["metadata"])
	at com.fasterxml.jackson.databind.JsonMappingException.wrapWithPath(JsonMappingException.java:401) ~[jackson-databind-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.databind.JsonMappingException.wrapWithPath(JsonMappingException.java:360) ~[jackson-databind-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.wrapAndThrow(BeanDeserializerBase.java:1964) ~[jackson-databind-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.vanillaDeserialize(BeanDeserializer.java:312) ~[jackson-databind-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:177) ~[jackson-databind-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.dataformat.xml.deser.WrapperHandlingDeserializer.deserialize(WrapperHandlingDeserializer.java:122) ~[jackson-dataformat-xml-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.dataformat.xml.deser.XmlDeserializationContext.readRootValue(XmlDeserializationContext.java:104) ~[jackson-dataformat-xml-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4917) ~[jackson-databind-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3725) ~[jackson-databind-2.18.2.jar:2.18.2]
	at org.cyclonedx.parsers.XmlParser.parse(XmlParser.java:86) ~[cyclonedx-core-java-10.1.0.jar:na]
	... 7 common frames omitted
Caused by: java.lang.NumberFormatException: For input string: "20242024202420242024"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67) ~[na:na]
	at java.base/java.lang.Long.parseLong(Long.java:709) ~[na:na]
	at java.base/java.lang.Long.parseLong(Long.java:832) ~[na:na]
	at java.base/java.text.DigitList.getLong(DigitList.java:196) ~[na:na]
	at java.base/java.text.DecimalFormat.parse(DecimalFormat.java:2228) ~[na:na]
	at java.base/java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1937) ~[na:na]
	at java.base/java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1545) ~[na:na]
	at java.base/java.text.DateFormat.parse(DateFormat.java:397) ~[na:na]
	at org.cyclonedx.util.TimestampUtils.parseTimestamp(TimestampUtils.java:33) ~[cyclonedx-core-java-10.1.0.jar:na]
	at org.cyclonedx.util.deserializer.MetadataDeserializer.setTimestamp(MetadataDeserializer.java:136) ~[cyclonedx-core-java-10.1.0.jar:na]
	at org.cyclonedx.util.deserializer.MetadataDeserializer.deserialize(MetadataDeserializer.java:79) ~[cyclonedx-core-java-10.1.0.jar:na]
	at org.cyclonedx.util.deserializer.MetadataDeserializer.deserialize(MetadataDeserializer.java:22) ~[cyclonedx-core-java-10.1.0.jar:na]
	at com.fasterxml.jackson.databind.deser.impl.MethodProperty.deserializeAndSet(MethodProperty.java:129) ~[jackson-databind-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.vanillaDeserialize(BeanDeserializer.java:310) ~[jackson-databind-2.18.2.jar:2.18.2]
	... 13 common frames omitted

The culprit seems to be the org.cyclonedx.util.TimestampUtils.parseTimestamp(TimestampUtils.java:33) method which uses a static singleton instance of SimpleDateFormat which is not thread safe. SDF relies on an instance of Calendar which is the actual culprit here.

A quick fix would be to use the new DateTimeFormatter class that is thread safe. Though you'd have to convert the parsing result to java.util.Date or rewrite the whole code to use the new time classes (like LocalDateTime) instead of java.util.Date.

A even quicker fix would be to simply make an instance of SDF (or TimestampUtils) per instance of MetadataDeserializer instead of a single static instance.

Code to reproduce the error

Below is a bit of code to reproduce this (randomly), obviously you need a bunch of sbom files in the inputdir

class Scratch {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(20);
        List<File> files = List.of(new File("inputdir").listFiles())
        List<Future<?>> futures = new ArrayList<>();
        for (File input : files) {
            futures.add(executorService.submit(new ParseTask(input)));
        }
        // wait for all tasks to finish
        futures.forEach(f -> {
            try {
                f.get();
            } catch (InterruptedException | ExecutionException e) {
                //
            }
        });
        executorService.shutdown();
    }

    private static class ParseTask implements Runnable {
        private final File input;

        public ParseTask(File input) {
            this.input = input;
        }

        public void run() {
            try {
                Parser parser = BomParserFactory.createParser(input);
                Bom bom = parser.parse(input);
            } catch (ParseException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

Workaround

The quickest workaround for now is to make a of copy the MetadataDeserializer class, replace the setTimestamp() method with a thread-safe implementation and use mixins to override the serialisation (by manipulating the mapper field on XmlParser through reflection)

        XmlParser xmlParser = new XmlParser();
        try {
            Field mapperField = XmlParser.class.getDeclaredField("mapper");
            mapperField.setAccessible(true);
            ObjectMapper mapper = (ObjectMapper) mapperField.get(xmlParser);
            mapper.registerModule(new CycloneDxMetadataThreadSafetyFixModule());
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
public class CycloneDxMetadataThreadSafetyFixModule extends SimpleModule {
    @Override
    public void setupModule(SetupContext context) {
        context.setMixInAnnotations(Metadata.class, MetadataMixin.class);
    }

    @JsonDeserialize(using = ThreadSafeMetadataDeserializer.class)
    public abstract static class MetadataMixin {
    }
}

// the ThreadSafeMetadataDeserializer is a vanilla copy of MetadataDeserializer that calls ThreadSafeTimestampUtils instead of TimestampUtils

public final class ThreadSafeTimestampUtils {
    public static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssX");

    private ThreadSafeTimestampUtils() {
    }

    public static Date parseTimestamp(String text) {
        try {
            TemporalAccessor parsed = FORMATTER.parse(text);
            LocalDateTime localDateTime = LocalDateTime.from(parsed);
            return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
        } catch (NullPointerException e) {
            return null;
        }
    }
}

This is obviously a pretty hacky way to fix this, but it solves the problem locally :)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions