Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,21 @@ private WireVoteResult handleWireVoteDelayRejected(JsonEnvelope msg) {
if (rejected.uuid.isEmpty() || rejected.service.isEmpty()) {
return new WireVoteResult(rejected.voteId, true);
}
VoteSite legacySite = null;
if (!ServiceSiteValidator.isValid(rejected.service)
&& ServiceSiteValidator.isValidConfiguredLegacyIdentifier(rejected.service)) {
for (VoteSite site : plugin.getVoteSiteManager().getVoteSites().toArray(new VoteSite[0])) {
if (site.isEnabled() && rejected.service.equalsIgnoreCase(site.getServiceSite())) {
legacySite = site;
break;
}
}
}
boolean configuredLegacy = legacySite != null;
if (!ServiceSiteValidator.isValid(rejected.service) && !configuredLegacy) {
plugin.getLogger().warning("Rejected VoteDelayRejected with invalid service site");
return new WireVoteResult(rejected.voteId, true);
}
UUID javaUuid;
try {
javaUuid = UUID.fromString(rejected.uuid);
Expand All @@ -620,8 +635,10 @@ private WireVoteResult handleWireVoteDelayRejected(JsonEnvelope msg) {
+ ServiceSiteValidator.sanitizeForLog(rejected.uuid));
return new WireVoteResult(rejected.voteId, true);
}
VoteSite voteSite = plugin.getVoteSiteManager()
.getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, rejected.service), true);
VoteSite voteSite = configuredLegacy
? legacySite
: plugin.getVoteSiteManager()
.getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, rejected.service), true);
if (voteSite == null) {
plugin.getLogger().warning("No voting site with the service site: '"
+ ServiceSiteValidator.sanitizeForLog(rejected.service) + "'");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ public final class ServiceSiteValidator {
/** Maximum accepted UTF-16 length for a service-site identifier. */
public static final int MAX_LENGTH = 2048;
private static final int MAX_LOG_LENGTH = 128;
private static final String FORMATTING_BOUNDARY = "\u2060";

private ServiceSiteValidator() {
}
Expand All @@ -17,17 +16,26 @@ private ServiceSiteValidator() {
*
* @param serviceSite service-site name supplied by a vote source
* @return {@code true} for a bounded, visible service-site name that does not
* contain structural formatting delimiters or control characters
* contain unsupported delimiters
*/
public static boolean isValid(String serviceSite) {
return isValid(serviceSite, false);
}

/** Bounded legacy identifier validation; only use for an already-configured site. */
public static boolean isValidConfiguredLegacyIdentifier(String serviceSite) {
return isValid(serviceSite, true);
}

private static boolean isValid(String serviceSite, boolean allowLegacyDelimiters) {
if (serviceSite == null || serviceSite.length() > MAX_LENGTH) {
return false;
}

boolean hasVisibleCharacter = false;
for (int offset = 0; offset < serviceSite.length();) {
int codePoint = serviceSite.codePointAt(offset);
if (isDisallowed(codePoint)) {
if (isDisallowed(codePoint) && !(allowLegacyDelimiters && isLegacyDelimiter(codePoint))) {
return false;
}
if (isVisibleBaseCharacter(codePoint)) {
Expand Down Expand Up @@ -66,27 +74,26 @@ public static String sanitizeForLog(String value) {
return sanitized.toString();
}

/**
* Keeps a validated external identifier visually unchanged while preventing a
* surrounding trusted template from completing a formatting token across a
* substitution boundary.
*/
/** Display-only representation; raw identifiers remain authoritative for matching/actions.
* Uses printable ASCII labels and space boundaries rather than font-dependent guards.
* Boundaries prevent surrounding template text from completing interpreter tokens. */
public static String inertForFormatting(String value) {
if (value == null || value.isEmpty()) return value == null ? "" : value;
StringBuilder inert = new StringBuilder(value.length() + 2).append(FORMATTING_BOUNDARY);
for (int offset = 0; offset < value.length();) {
int codePoint = value.codePointAt(offset);
inert.appendCodePoint(codePoint);
if (codePoint == '%' || codePoint == '&') inert.append(FORMATTING_BOUNDARY);
offset += Character.charCount(codePoint);
}
return inert.append(FORMATTING_BOUNDARY).toString();
if (value == null || value.isEmpty()) return "";
return " " + value.replace("&", "(ampersand)").replace("%", "(percent)")
.replace("§", "(section)").replace("{", "(open brace)").replace("}", "(close brace)")
.replace("[", "(open bracket)").replace("]", "(close bracket)")
.replace("\"", "(double quote)").replace("'", "(quote)")
.replace("`", "(backtick)").replace("\\", "(backslash)") + " ";
}

private static boolean isLegacyDelimiter(int codePoint) {
return codePoint == '[' || codePoint == ']' || codePoint == '\'' || codePoint == '"'
|| codePoint == '`' || codePoint == '\\' || codePoint == '\u00A7';
}

private static boolean isDisallowed(int codePoint) {
if (codePoint == '[' || codePoint == ']' || codePoint == '\'' || codePoint == '"' || codePoint == '`'
|| codePoint == '\u00A7'
|| codePoint == '\\' || codePoint == '{' || codePoint == '}') {
|| codePoint == '\\' || codePoint == '\u00A7') {
return true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ public void setDisplayName(String displayName) {
public String getDisplayNameForFormatting() {
return displayNameFallback ? ServiceSiteValidator.inertForFormatting(getDisplayName()) : getDisplayName();
}

/**
* Inits the.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,97 @@ void receiptReleaseBypassesOrderingOnlyAfterItsReceiptIsDurable() {
assertTrue(voteRouter.hasDurableReceiptForRelease(release));
}

@Test
void delayRejectionRejectsInterpreterDelimitersBeforeResolvingUsersOrSites() {
ProcessedVoteCache cache = mock(ProcessedVoteCache.class);
when(cache.complete(any(UUID.class))).thenReturn(true);
BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin,
mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class),
mock(BackendVotePartySync.class), cache);
for (String service : new String[] { "[Spoofed]", "bad\\name", "bad\nname", "bad\u2060name" }) {
for (boolean reliable : new boolean[] { false, true }) {
configureDelayRejection(true);
JsonEnvelope rejection = VotingPluginWire.voteDelayRejected("Player", PLAYER_UUID.toString(),
service, true, UUID.randomUUID());
if (reliable) rejection = VotingPluginWire.requestVoteDeliveryAcknowledgement(rejection);
AtomicReference<OrderedVoteOutcome> outcome = new AtomicReference<>();
voteRouter.handleOrderedVote(rejection, outcome::set);
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
verify(voteSiteManager, never()).getVoteSiteName(true, service);
}
}
verify(plugin.getVotingPluginUserManager(), never()).getVotingPluginUser(PLAYER_UUID, "Player");
verify(user, never()).cache();
}

@Test
void configuredLegacyDelayRejectionNamesKeepRewardsWithoutCreatingSites() {
for (String service : new String[] { "Old's Site", "[Old]", "Old`Site", "Old\\Site", "Old\"Site", "§kOldSite" }) {
for (boolean reliable : new boolean[] { false, true }) {
VoteSite site = configureDelayRejection(true);
when(site.getServiceSite()).thenReturn(service);
when(voteSiteManager.getVoteSites()).thenReturn(java.util.List.of(site));
UUID id = UUID.randomUUID();
ProcessedVoteCache cache = mock(ProcessedVoteCache.class);
when(cache.reserveWithOutcome(id)).thenReturn(Reservation.RESERVED);
when(cache.complete(id)).thenReturn(true);
BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin,
mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class),
mock(BackendVotePartySync.class), cache);
JsonEnvelope message = VotingPluginWire.voteDelayRejected("Player", PLAYER_UUID.toString(),
service, true, id);
if (reliable) message = VotingPluginWire.requestVoteDeliveryAcknowledgement(message);
AtomicReference<OrderedVoteOutcome> outcome = new AtomicReference<>();
voteRouter.handleOrderedVote(message, outcome::set);
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
verify(site).giveWaitUntilVoteDelayRewards(user, false, true);
verify(voteSiteManager, never()).getVoteSiteName(true, service);
verify(voteSiteManager, never()).getVoteSite(service, true);
}
}
}

@Test
void legacyDelayRejectionIgnoresDisplayAliasesAndFindsTheActualService() {
for (boolean reliable : new boolean[] { false, true }) {
String service = "[Old]";
VoteSite alias = configureDelayRejection(true);
when(alias.getDisplayName()).thenReturn(service);
when(alias.getServiceSite()).thenReturn("different.example");
when(voteSiteManager.hasConfiguredVoteSite(service)).thenReturn(true);
when(voteSiteManager.resolveVoteSite(service, true)).thenReturn(alias);
when(voteSiteManager.getVoteSites()).thenReturn(java.util.List.of(alias));
ProcessedVoteCache cache = mock(ProcessedVoteCache.class);
when(cache.complete(any(UUID.class))).thenReturn(true);
when(cache.reserveWithOutcome(any(UUID.class))).thenReturn(Reservation.RESERVED);
BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin,
mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class),
mock(BackendVotePartySync.class), cache);
JsonEnvelope message = VotingPluginWire.voteDelayRejected("Player", PLAYER_UUID.toString(),
service, true, UUID.randomUUID());
if (reliable) message = VotingPluginWire.requestVoteDeliveryAcknowledgement(message);
AtomicReference<OrderedVoteOutcome> outcome = new AtomicReference<>();
voteRouter.handleOrderedVote(message, outcome::set);
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
verify(alias, never()).giveWaitUntilVoteDelayRewards(user, false, true);
verify(user, never()).cache();

VoteSite actual = mock(VoteSite.class);
when(actual.isEnabled()).thenReturn(true);
when(actual.isWaitUntilVoteDelay()).thenReturn(true);
when(actual.getServiceSite()).thenReturn("[oLD]");
when(voteSiteManager.getVoteSites()).thenReturn(java.util.List.of(alias, actual));
JsonEnvelope actualMessage = VotingPluginWire.voteDelayRejected("Player", PLAYER_UUID.toString(),
service, true, UUID.randomUUID());
if (reliable) actualMessage = VotingPluginWire.requestVoteDeliveryAcknowledgement(actualMessage);
voteRouter.handleOrderedVote(actualMessage, outcome::set);
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
verify(actual).giveWaitUntilVoteDelayRewards(user, false, true);
verify(alias, never()).giveWaitUntilVoteDelayRewards(user, false, true);
org.mockito.Mockito.clearInvocations(user);
}
}

@Test
void reliableDelayRejectionExecutesOnceAndPersistsItsVoteId() {
UUID voteId = UUID.randomUUID();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -834,7 +834,7 @@ void playerMessageOnlyRewardIsSupported() throws Exception {
runtime.scheduler().onServerTick();
assertEquals(NeoForgeRewardReplayService.Status.COMPLETED,
completion.get(5, TimeUnit.SECONDS).get(0).status());
assertEquals(List.of("Thanks Alex from \u2060Service\u2060 at Supported Display"), actions.rendered);
assertEquals(List.of("Thanks Alex from Service at Supported Display"), actions.rendered);
}
}

Expand Down Expand Up @@ -878,7 +878,7 @@ void playerMessageKeepsConfiguredServiceFormattingLiteral() throws Exception {

assertEquals(NeoForgeRewardReplayService.Status.COMPLETED,
runOne(runtime, replay, actions).status());
assertEquals(List.of("Thanks Alex from \u2060&\u2060aService\u2060"), actions.rendered);
assertEquals(List.of("Thanks Alex from (ampersand)aService "), actions.rendered);
assertTrue(runtime.deferredVotes().pending(playerId).isEmpty());
}
}
Expand All @@ -900,7 +900,7 @@ void playerMessageKeepsServicePlaceholderTextInert() throws Exception {

assertEquals(NeoForgeRewardReplayService.Status.COMPLETED,
runOne(runtime, replay, actions).status());
assertEquals(List.of("Thanks Alex from \u2060%\u2060player%\u2060\u2060"), actions.rendered);
assertEquals(List.of("Thanks Alex from (percent)player(percent) "), actions.rendered);
assertTrue(runtime.deferredVotes().pending(playerId).isEmpty());
}
}
Expand All @@ -923,7 +923,7 @@ void playerMessageKeepsFallbackSiteNamePlaceholderTextInert() throws Exception {

assertEquals(NeoForgeRewardReplayService.Status.COMPLETED,
runOne(runtime, replay, actions).status());
assertEquals(List.of("Thanks Alex from \u2060%\u2060player%\u2060\u2060"), actions.rendered);
assertEquals(List.of("Thanks Alex from (percent)player(percent) "), actions.rendered);
assertTrue(runtime.deferredVotes().pending(playerId).isEmpty());
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ class PendingIncomingVoteJournalTest {
@TempDir
Path temporaryDirectory;

@Test
void legacyDelimiterBearingVotesRemainReplayableWithOtherQueuedVotes() throws Exception {
var journal = new PendingIncomingVoteJournal(temporaryDirectory);
var votes = List.of(new VoteTimeQueue(UUID.randomUUID(), "Player", "R&D", 1L),
new VoteTimeQueue(UUID.randomUUID(), "Player", "https://example.com/site%20name", 2L),
new VoteTimeQueue(UUID.randomUUID(), "Player", "NormalSite", 3L));
journal.merge(votes);
var recovered = journal.load();
assertEquals(votes.stream().map(VoteTimeQueue::getService).toList(),
recovered.stream().map(VoteTimeQueue::getService).toList());
assertEquals(votes.stream().map(VoteTimeQueue::getVoteId).toList(),
recovered.stream().map(VoteTimeQueue::getVoteId).toList());
}

@Test
void atomicJournalPreservesStableIdAndPartialEffectFences() throws Exception {
PendingIncomingVoteJournal journal = new PendingIncomingVoteJournal(temporaryDirectory);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,48 +4,81 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.concurrent.atomic.AtomicInteger;

import org.junit.jupiter.api.Test;

import com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate;
import com.bencodez.votingplugin.util.ServiceSiteValidator;

class ServiceSiteValidatorTest {

@Test
void acceptsCommonServiceSiteNames() {
for (String serviceSite : new String[] { "PlanetMinecraft.com", "Minecraft Server List", "Crafty.gg",
"https://example.com/vote", "https://list.example/vote?id=1&source=proxy#top",
"Research & Development", "R&D", "site_name-2", "Site, Other; Network!",
"site%20name", "site%2Bname", "site%20%2Bname", "site%20name%2Bnetwork",
"site%20name_with%20spaces", "Top 100% Servers", "%player_name%", "%javascript_vote%",
"%be_secret%20", "%25player_name%25", "site%", "site%2", "site%GG",
"&kSpoofed", "&aGreen", "&rReset",
"&xHex", "&#ff0000Spoofed", "&#ABCDEFText",
"Serviço de votação",
"Site\u00A0Name", "Site\uFE0F", "Cafe\u0301", "Site\u3164Name" }) {
"https://example.com/vote", "https://list.example/vote?id=1#top",
"https://example.com/search?q=site+name+network", "site_name-2", "Site, Other; Network!",
"Serviço de votação", "Site\u00A0Name", "Site\uFE0F", "Cafe\u0301", "Site\u3164Name" }) {
assertTrue(ServiceSiteValidator.isValid(serviceSite), serviceSite);
}
}

@Test
void rejectsUnsupportedCharacters() {
for (String serviceSite : new String[] { "[Javascript=1]", "Site's", "\"Site\"", "Site`Name",
"Site\\Name", "Site\nName", "Site\tName", "Site\u0000Name", "Site\u200BName",
"{player}", "\u00A7kSpoofed", "\u00A7aGreen" }) {
assertFalse(ServiceSiteValidator.isValid(serviceSite), serviceSite);
void acceptsLegacyFormattingIdentifiersWithoutChangingMatching() {
for (String value : new String[] { "R&D", "&kSpoofed", "%player_name%", "{RED}",
"https://list.example/vote?id=1&source=proxy", "https://example.com/site%20name" }) {
assertTrue(ServiceSiteValidator.isValid(value), value);
String display = ServiceSiteValidator.inertForFormatting(value);
assertFalse(display.contains("&") || display.contains("%") || display.contains("§")
|| display.contains("{") || display.contains("}"));
assertFalse(display.contains("\u2060"));
}
}

@Test
void formattingGuardPreventsTokensAcrossTemplateBoundaries() {
String value = "%player_name% &aGreen &#ff0000Red kSpoofed&";
String guarded = ServiceSiteValidator.inertForFormatting(value);
AtomicInteger executions = new AtomicInteger();
for (String[] layout : new String[][] {
{ "[%SiteName%]", "Javascript=1" },
{ "[Java%SiteName%]", "script=1" },
{ "[%SiteName%script=1]", "Java" } }) {
assertTrue(ServiceSiteValidator.isValid(layout[1]));
String rendered = layout[0].replace("%SiteName%",
ServiceSiteValidator.inertForFormatting(layout[1]));
assertEquals(rendered, JavascriptTextTemplate.parse(rendered).evaluate(text -> text, script -> {
executions.incrementAndGet();
return "executed";
}));
}
assertEquals(0, executions.get());
assertEquals("& kSpoofed ", "&%SiteName%".replace("%SiteName%",
ServiceSiteValidator.inertForFormatting("kSpoofed")));
assertEquals("% player_name %", "%" + ServiceSiteValidator.inertForFormatting("player_name") + "%");
assertEquals("", ServiceSiteValidator.inertForFormatting(null));
assertEquals("", ServiceSiteValidator.inertForFormatting(""));
}

@Test
void legacyConfiguredValidationAllowsPunctuationButKeepsBoundsAndControlChecks() {
for (String service : new String[] { "Old's Site", "[Javascript=1]", "Old`Site", "Old\\Site", "§kOldSite" }) {
assertFalse(ServiceSiteValidator.isValid(service));
assertTrue(ServiceSiteValidator.isValidConfiguredLegacyIdentifier(service));
String display = ServiceSiteValidator.inertForFormatting(service);
assertFalse(display.contains("[") || display.contains("]") || display.contains("'")
|| display.contains("`") || display.contains("\\") || display.contains("§"));
}
for (String service : new String[] { "bad\nsite", "bad\u2060site", "x".repeat(2049), " " }) {
assertFalse(ServiceSiteValidator.isValidConfiguredLegacyIdentifier(service));
}
assertFalse(ServiceSiteValidator.isValidConfiguredLegacyIdentifier(null));
}

assertFalse(("&" + guarded + "a").contains("&k"));
assertFalse(("%" + guarded + "player_name%").contains("%kSpoofed&player_name%"));
assertFalse(guarded.contains("%player_name%"));
assertFalse(guarded.contains("&a"));
assertFalse(guarded.contains("&#ff0000"));
assertEquals(value, guarded.replace("\u2060", ""));
@Test
void rejectsUnsupportedCharacters() {
for (String value : new String[] { "[Javascript=1]", "Site's", "\"Site\"", "Site`Name",
"§kSpoofed", "Site\\Name", "Site\nName", "Site\tName", "Site\u0000Name", "Site\u200BName" }) {
assertFalse(ServiceSiteValidator.isValid(value), value);
}
}

@Test
Expand Down
Loading
Loading