Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions buildSrc/src/main/kotlin/Versions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ object Versions {
const val VELOCITY_API = "3.4.0"

const val JETBRAINS_ANNOTATIONS = "26.1.0"
const val GSON = "2.13.2"


}
3 changes: 2 additions & 1 deletion multification-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ plugins {
dependencies {
compileOnlyApi("net.kyori:adventure-api:${Versions.ADVENTURE_API}")
api("org.jetbrains:annotations:${Versions.JETBRAINS_ANNOTATIONS}")
implementation("com.google.code.gson:gson:${Versions.GSON}")

testImplementation("net.kyori:adventure-api:${Versions.ADVENTURE_API}")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.eternalcode.multification.viewer.ViewerProvider;
import com.eternalcode.multification.adventure.AudienceConverter;
import com.eternalcode.multification.executor.AsyncExecutor;
import com.eternalcode.multification.notice.Notice;
import com.eternalcode.multification.notice.NoticeBroadcast;
import com.eternalcode.multification.notice.NoticeBroadcastImpl;
import com.eternalcode.multification.notice.provider.NoticeProvider;
Expand Down Expand Up @@ -126,6 +127,14 @@ public void all(NoticeProvider<TRANSLATION> extractor, Formatter... formatters)
.send();
}

public String serialize(Notice notice) {
return notice.serialize(this.noticeRegistry);
}

public Notice deserialize(String raw) {
return Notice.deserialize(raw, this.noticeRegistry);
}

@ApiStatus.Internal
public NoticeResolverRegistry getNoticeRegistry() {
return noticeRegistry;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.eternalcode.multification.notice;

import com.eternalcode.multification.notice.resolver.NoticeContent;
import com.eternalcode.multification.notice.resolver.NoticeResolverDefaults;
import com.eternalcode.multification.notice.resolver.NoticeResolverRegistry;
import com.eternalcode.multification.notice.resolver.actionbar.ActionbarContent;
import com.eternalcode.multification.notice.resolver.bossbar.BossBarContent;
import com.eternalcode.multification.notice.resolver.chat.ChatContent;
Expand All @@ -25,6 +27,8 @@

public class Notice {

private static final NoticeResolverRegistry DEFAULT_NOTICE_REGISTRY = NoticeResolverDefaults.createRegistry();

private final Map<NoticeKey<?>, NoticePart<?>> parts = new LinkedHashMap<>();

protected Notice(Map<NoticeKey<?>, NoticePart<?>> parts) {
Expand Down Expand Up @@ -149,6 +153,22 @@ public static Notice empty() {
return new Notice(Collections.emptyMap());
}

public String serialize() {
return serialize(DEFAULT_NOTICE_REGISTRY);
}

public String serialize(NoticeResolverRegistry noticeRegistry) {
return NoticeJsonCodec.serialize(this, noticeRegistry);
}

public static Notice deserialize(String raw) {
return deserialize(raw, DEFAULT_NOTICE_REGISTRY);
}

public static Notice deserialize(String raw, NoticeResolverRegistry noticeRegistry) {
return NoticeJsonCodec.deserialize(raw, noticeRegistry);
}

public static Builder builder() {
return new Builder();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package com.eternalcode.multification.notice;

import com.eternalcode.multification.notice.resolver.NoticeDeserializeResult;
import com.eternalcode.multification.notice.resolver.NoticeContent;
import com.eternalcode.multification.notice.resolver.NoticeResolverRegistry;
import com.eternalcode.multification.notice.resolver.NoticeSerdesResult;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;
import com.google.gson.JsonPrimitive;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

final class NoticeJsonCodec {

private NoticeJsonCodec() {
}

static String serialize(Notice notice, NoticeResolverRegistry noticeRegistry) {
JsonObject root = new JsonObject();

for (NoticePart<?> part : notice.parts()) {
NoticeSerdesResult serializedPart = noticeRegistry.serialize(part);
root.add(part.noticeKey().key(), toJsonElement(serializedPart));
}

return root.toString();
}

static Notice deserialize(String raw, NoticeResolverRegistry noticeRegistry) {
JsonElement parsed = JsonParser.parseString(raw);
if (!parsed.isJsonObject()) {
throw new IllegalArgumentException("Notice JSON must be an object");
}

Notice.Builder builder = Notice.builder();
JsonObject root = parsed.getAsJsonObject();

for (Map.Entry<String, JsonElement> entry : root.entrySet()) {
String key = entry.getKey();
NoticeSerdesResult result = toSerdesResult(key, entry.getValue());
Optional<NoticeDeserializeResult<?>> deserialized = noticeRegistry.deserialize(key, result);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard unknown notice keys before delegating deserialization

When deserializing arbitrary JSON, this line calls noticeRegistry.deserialize(key, result) before validating that key is registered; for unknown keys, NoticeResolverRegistry.deserialize dereferences a null resolver set and throws a NullPointerException, so the intended IllegalArgumentException("Unsupported notice key") path is never reached. Any typo or extra field in input JSON will crash deserialization with an opaque NPE instead of a controlled validation error.

Useful? React with 👍 / 👎.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The deserialize method calls noticeRegistry.deserialize(key, result), which can lead to a NullPointerException if the key provided in the JSON is not registered in the noticeRegistry. This is because NoticeResolverRegistry.deserialize (line 37 in the context file) does not check if the returned set of resolvers is null before iterating over it. An attacker can provide a malicious JSON string with an unknown key to cause the application to throw an unhandled exception, potentially leading to a Denial of Service (DoS) if the exception is not caught by the caller.


if (deserialized.isEmpty()) {
throw new IllegalArgumentException("Unsupported notice key: " + key);
}

withPart(builder, deserialized.get());
}

return builder.build();
}

private static JsonElement toJsonElement(NoticeSerdesResult result) {
if (result instanceof NoticeSerdesResult.Single single) {
return new JsonPrimitive(single.element());
}

if (result instanceof NoticeSerdesResult.Multiple multiple) {
JsonArray array = new JsonArray();
for (String element : multiple.elements()) {
array.add(element);
}
return array;
}

if (result instanceof NoticeSerdesResult.Section section) {
JsonObject object = new JsonObject();
for (Map.Entry<String, String> sectionEntry : section.elements().entrySet()) {
object.addProperty(sectionEntry.getKey(), sectionEntry.getValue());
}
return object;
}

if (result instanceof NoticeSerdesResult.Empty) {
return JsonNull.INSTANCE;
}
Comment on lines +81 to +83

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve empty chat entries in JSON round-trip

Serializing NoticeSerdesResult.Empty as JSON null causes round-trip failure for valid notices with empty chat content (e.g., Notice.builder().chat(List.of()).build()): deserialization maps null back to Empty, ChatResolver.deserialize returns Optional.empty(), and the codec then throws Unsupported notice key. This means the new serialize/deserialize API cannot reliably restore all notices it can serialize.

Useful? React with 👍 / 👎.


throw new IllegalArgumentException("Unsupported result type: " + result.getClass().getName());
}
Comment on lines +60 to +86
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The if-instanceof chain can be replaced with a switch expression. Since NoticeSerdesResult is a sealed interface and the project uses Java 17, a switch expression is more concise and provides compile-time safety for exhaustiveness.

    private static JsonElement toJsonElement(NoticeSerdesResult result) {
        return switch (result) {
            case NoticeSerdesResult.Single single -> new JsonPrimitive(single.element());
            case NoticeSerdesResult.Multiple multiple -> {
                JsonArray array = new JsonArray();
                multiple.elements().forEach(array::add);
                yield array;
            }
            case NoticeSerdesResult.Section section -> {
                JsonObject object = new JsonObject();
                section.elements().forEach(object::addProperty);
                yield object;
            }
            case NoticeSerdesResult.Empty empty -> JsonNull.INSTANCE;
        };
    }


private static NoticeSerdesResult toSerdesResult(String key, JsonElement element) {
if (element.isJsonNull()) {
return new NoticeSerdesResult.Empty();
}

if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) {
return new NoticeSerdesResult.Single(element.getAsString());
}

if (element.isJsonArray()) {
return new NoticeSerdesResult.Multiple(toStringList(key, element.getAsJsonArray()));
}

if (element.isJsonObject()) {
return new NoticeSerdesResult.Section(toStringMap(key, element.getAsJsonObject()));
}

throw new JsonParseException("Unsupported JSON value for key '" + key + "'");
}

private static List<String> toStringList(String key, JsonArray jsonArray) {
List<String> values = new ArrayList<>();
for (JsonElement jsonElement : jsonArray) {
if (!jsonElement.isJsonPrimitive() || !jsonElement.getAsJsonPrimitive().isString()) {
throw new JsonParseException("All array elements for key '" + key + "' must be strings");
}
values.add(jsonElement.getAsString());
}
return values;
}
Comment on lines +108 to +117
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This method can be simplified by using Java Streams. This would make the code more declarative and potentially more readable.

    private static List<String> toStringList(String key, JsonArray jsonArray) {
        return jsonArray.asList().stream()
            .map(jsonElement -> {
                if (!jsonElement.isJsonPrimitive() || !jsonElement.getAsJsonPrimitive().isString()) {
                    throw new JsonParseException("All array elements for key '" + key + "' must be strings");
                }
                return jsonElement.getAsString();
            })
            .toList();
    }


private static Map<String, String> toStringMap(String key, JsonObject jsonObject) {
Map<String, String> values = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> jsonEntry : jsonObject.entrySet()) {
JsonElement jsonElement = jsonEntry.getValue();
if (!jsonElement.isJsonPrimitive() || !jsonElement.getAsJsonPrimitive().isString()) {
throw new JsonParseException("All object values for key '" + key + "' must be strings");
}
values.put(jsonEntry.getKey(), jsonElement.getAsString());
}
return values;
}
Comment on lines +119 to +129
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This method can be refactored to use Java Streams and Collectors.toMap for a more functional and concise implementation. This maintains the use of LinkedHashMap to preserve insertion order.

    private static Map<String, String> toStringMap(String key, JsonObject jsonObject) {
        return jsonObject.entrySet().stream()
            .collect(java.util.stream.Collectors.toMap(
                Map.Entry::getKey,
                entry -> {
                    JsonElement jsonElement = entry.getValue();
                    if (!jsonElement.isJsonPrimitive() || !jsonElement.getAsJsonPrimitive().isString()) {
                        throw new JsonParseException("All object values for key '" + key + "' must be strings");
                    }
                    return jsonElement.getAsString();
                },
                (v1, v2) -> v2,
                LinkedHashMap::new
            ));
    }


private static <T extends NoticeContent> void withPart(
Notice.Builder builder,
NoticeDeserializeResult<T> noticeResult
) {
builder.withPart(noticeResult.noticeKey(), noticeResult.content());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.eternalcode.multification.viewer.ViewerProvider;
import com.eternalcode.multification.adventure.AudienceConverter;
import com.eternalcode.multification.notice.Notice;
import java.time.Duration;
import com.eternalcode.multification.shared.Replacer;
import java.util.ArrayList;
import java.util.Collection;
Expand All @@ -13,6 +14,7 @@
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -141,4 +143,25 @@ void test() {
.containsExactly(DEFAULT_MESSAGE, OTHER_MESSAGE, "-INNER- -GLOBAL-");
}

@Test
@DisplayName("Should serialize and deserialize notice as json")
void shouldSerializeAndDeserializeNoticeAsJson() {
MyMultification multification = new MyMultification();
Notice notice = Notice.builder()
.chat("line-1", "line-2")
.actionBar("action")
.title("title", "subtitle")
.times(Duration.ofSeconds(1), Duration.ofSeconds(2), Duration.ofSeconds(3))
.build();

String raw = multification.serialize(notice);
Notice restored = multification.deserialize(raw);

assertThat(raw)
.contains("\"chat\"")
.contains("\"actionbar\"")
.contains("\"times\"");
assertEquals(notice.parts(), restored.parts());
}

}