CVE-2023-3432 (published 2023-06-27T15:15Z)

Server-Side Request Forgery (SSRF) in GitHub repository plantuml/plantuml prior to 1.2023.9.

CVSS v3 base score: 10.0, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N)

Weakness information:
cwe_id: CWE-918
cwe_name: Server-Side Request Forgery (SSRF)
description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
extended_description: By providing URLs to unexpected hosts or ports, attackers can make it appear that the server is sending the request, possibly bypassing access controls such as firewalls that prevent the attackers from accessing the URLs directly. The server can be used as a proxy to conduct port scanning of hosts in internal networks, use other URLs such as that can access documents on the system (using file://), or use other protocols such as gopher:// or tftp://, which may provide greater control over the contents of requests.
         

Fix information:
repo_url: https://github.com/plantuml/plantuml
commit_details: repo_url: https://github.com/plantuml/plantuml
author: Arnaud Roques
author_date: 2023-06-12 22:49:47+02:00
msg: fix: improve filelist support and nwdiag
repository: repo_url: https://github.com/plantuml/plantuml
repo_name: plantuml/plantuml
description: Generate diagrams from textual description
repo_language: Java
owner: plantuml
file_changes: filename: gradle.properties
change_type: ModificationType.MODIFY
diff: @@ -1,4 +1,4 @@
 # Warning, "version" should be the same in gradle.properties and Version.java
 # Any idea anyone how to magically synchronize those :-) ?
-version = 1.2023.9beta3
+version = 1.2023.9beta4
 org.gradle.workers.max = 3
\ No newline at end of file

programming_language: Batchfile
filename: AParentFolderRegular.java
change_type: ModificationType.MODIFY
diff: @@ -57,15 +57,15 @@ public class AParentFolderRegular implements AParentFolder {
 		final SFile filecurrent;
 		// Log.info("AParentFolderRegular::looking for " + nameOrPath);
 		// Log.info("AParentFolderRegular::dir = " + dir);
-		if (dir == null) {
+		if (dir == null)
 			filecurrent = new SFile(nameOrPath);
-		} else {
+		else
 			filecurrent = dir.getAbsoluteFile().file(nameOrPath);
-		}
+
 		// Log.info("AParentFolderRegular::Filecurrent " + filecurrent);
-		if (filecurrent.exists()) {
+		if (filecurrent.exists())
 			return new AFileRegular(filecurrent.getCanonicalFile());
-		}
+
 		return null;
 	}
 

programming_language: Java
filename: FilesEntry.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,111 @@
+/* ========================================================================
+ * PlantUML : a free UML diagram generator
+ * ========================================================================
+ *
+ * (C) Copyright 2009-2024, Arnaud Roques
+ *
+ * Project Info:  https://plantuml.com
+ * 
+ * If you like this project or if you find it useful, you can support us at:
+ * 
+ * https://plantuml.com/patreon (only 1$ per month!)
+ * https://plantuml.com/paypal
+ * 
+ * This file is part of PlantUML.
+ *
+ * PlantUML is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * PlantUML distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301,
+ * USA.
+ *
+ *
+ * Original Author:  Arnaud Roques
+ *
+ */
+package net.sourceforge.plantuml.filesdiagram;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import net.sourceforge.plantuml.klimt.UTranslate;
+import net.sourceforge.plantuml.klimt.creole.Display;
+import net.sourceforge.plantuml.klimt.drawing.UGraphic;
+import net.sourceforge.plantuml.klimt.font.FontConfiguration;
+import net.sourceforge.plantuml.klimt.geom.HorizontalAlignment;
+import net.sourceforge.plantuml.klimt.shape.TextBlock;
+import net.sourceforge.plantuml.style.ISkinParam;
+
+public class FilesEntry implements Iterable<FilesEntry> {
+
+	private final String name;
+	private FilesType type;
+	private List<FilesEntry> children = new ArrayList<>();
+
+	public FilesEntry(String name, FilesType type) {
+		this.name = name;
+		this.type = type;
+	}
+
+	public FilesEntry addRawEntry(String raw) {
+		final int x = raw.indexOf('/');
+		if (x == -1) {
+			final FilesEntry result = new FilesEntry(raw, FilesType.DATA);
+			children.add(result);
+			return result;
+		}
+		final FilesEntry folder = getOrCreateFolder(raw.substring(0, x));
+		final String remain = raw.substring(x + 1);
+		if (remain.length() == 0)
+			return folder;
+		return folder.addRawEntry(remain);
+	}
+
+	private FilesEntry getOrCreateFolder(String folderName) {
+		for (FilesEntry child : children)
+			if (child.type == FilesType.FOLDER && child.getName().equals(folderName))
+				return child;
+
+		final FilesEntry result = new FilesEntry(folderName, FilesType.FOLDER);
+		children.add(result);
+		return result;
+	}
+
+	@Override
+	public Iterator<FilesEntry> iterator() {
+		return Collections.unmodifiableCollection(children).iterator();
+	}
+
+	public String getName() {
+		return name;
+	}
+
+	public String getEmoticon() {
+		if (type == FilesType.FOLDER)
+			return "<:1f4c2:>";
+		// return "<:1f4c1:>";
+		return "<:1f4c4:>";
+	}
+
+	public UGraphic drawAndMove(UGraphic ug, FontConfiguration fontConfiguration, ISkinParam skinParam, double deltax) {
+		final Display display = Display.getWithNewlines(getEmoticon() + getName());
+		TextBlock result = display.create(fontConfiguration, HorizontalAlignment.LEFT, skinParam);
+		result.drawU(ug.apply(UTranslate.dx(deltax)));
+		ug = ug.apply(UTranslate.dy(result.calculateDimension(ug.getStringBounder()).getHeight() + 2));
+		for (FilesEntry child : children)
+			ug = child.drawAndMove(ug, fontConfiguration, skinParam, deltax + 21);
+		return ug;
+	}
+
+}

programming_language: Java
filename: FilesListing.java
change_type: ModificationType.MODIFY
diff: @@ -34,26 +34,19 @@
  */
 package net.sourceforge.plantuml.filesdiagram;
 
-import java.util.ArrayList;
-import java.util.List;
-
-import net.sourceforge.plantuml.klimt.UTranslate;
-import net.sourceforge.plantuml.klimt.creole.Display;
 import net.sourceforge.plantuml.klimt.drawing.UGraphic;
 import net.sourceforge.plantuml.klimt.font.FontConfiguration;
 import net.sourceforge.plantuml.klimt.font.StringBounder;
 import net.sourceforge.plantuml.klimt.font.UFont;
-import net.sourceforge.plantuml.klimt.geom.HorizontalAlignment;
 import net.sourceforge.plantuml.klimt.geom.XDimension2D;
 import net.sourceforge.plantuml.klimt.shape.AbstractTextBlock;
-import net.sourceforge.plantuml.klimt.shape.TextBlock;
 import net.sourceforge.plantuml.style.ISkinParam;
 
 public class FilesListing extends AbstractTextBlock {
 
 	private final ISkinParam skinParam;
 	private final FontConfiguration fontConfiguration = FontConfiguration.blackBlueTrue(UFont.courier(14));
-	private final List<String> tmp = new ArrayList<>();
+	private final FilesEntry root = new FilesEntry("", FilesType.FOLDER);
 
 	public FilesListing(ISkinParam skinParam) {
 		this.skinParam = skinParam;
@@ -66,18 +59,13 @@ public class FilesListing extends AbstractTextBlock {
 
 	@Override
 	public void drawU(UGraphic ug) {
-		for (String s : tmp) {
-			final Display display = Display.getWithNewlines("<:1f4c4:>" + s);
-			TextBlock result = display.create(fontConfiguration, HorizontalAlignment.LEFT, skinParam);
-			result.drawU(ug);
-			ug = ug.apply(UTranslate.dy(result.calculateDimension(ug.getStringBounder()).getHeight()));
-		}
-
+		for (FilesEntry ent : root)
+			ug = ent.drawAndMove(ug, fontConfiguration, skinParam, 0);
 	}
 
 	public void add(String line) {
 		if (line.startsWith("/"))
-			tmp.add(line.substring(1));
+			root.addRawEntry(line.substring(1));
 	}
 
 }

programming_language: Java
filename: FilesType.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,40 @@
+/* ========================================================================
+ * PlantUML : a free UML diagram generator
+ * ========================================================================
+ *
+ * (C) Copyright 2009-2024, Arnaud Roques
+ *
+ * Project Info:  https://plantuml.com
+ * 
+ * If you like this project or if you find it useful, you can support us at:
+ * 
+ * https://plantuml.com/patreon (only 1$ per month!)
+ * https://plantuml.com/paypal
+ * 
+ * This file is part of PlantUML.
+ *
+ * PlantUML is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * PlantUML distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301,
+ * USA.
+ *
+ *
+ * Original Author:  Arnaud Roques
+ *
+ */
+package net.sourceforge.plantuml.filesdiagram;
+
+public enum FilesType {
+	FOLDER, DATA;
+
+}

programming_language: Java
filename: SpriteColor.java
change_type: ModificationType.MODIFY
diff: @@ -66,26 +66,26 @@ public class SpriteColor implements Sprite {
 	}
 
 	public void setGray(int x, int y, int level) {
-		if (x < 0 || x >= width) {
+		if (x < 0 || x >= width)
 			return;
-		}
-		if (y < 0 || y >= height) {
+
+		if (y < 0 || y >= height)
 			return;
-		}
-		if (level < 0 || level >= 16) {
+
+		if (level < 0 || level >= 16)
 			throw new IllegalArgumentException();
-		}
+
 		gray[y][x] = level;
 		color[y][x] = -1;
 	}
 
 	public void setColor(int x, int y, int col) {
-		if (x < 0 || x >= width) {
+		if (x < 0 || x >= width)
 			return;
-		}
-		if (y < 0 || y >= height) {
+
+		if (y < 0 || y >= height)
 			return;
-		}
+
 		gray[y][x] = -1;
 		color[y][x] = col;
 	}
@@ -99,14 +99,14 @@ public class SpriteColor implements Sprite {
 	}
 
 	public UImage toUImage(ColorMapper colorMapper, HColor backcolor, HColor forecolor) {
-		final BufferedImage im = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
+		final BufferedImage im = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
 
-		if (backcolor == null) {
+		if (backcolor == null)
 			backcolor = HColors.WHITE;
-		}
-		if (forecolor == null) {
+
+		if (forecolor == null)
 			forecolor = HColors.BLACK;
-		}
+
 		final HColorGradient gradient = HColors.gradient(backcolor, forecolor, '\0');
 		for (int col = 0; col < width; col++) {
 			for (int line = 0; line < height; line++) {

programming_language: Java
filename: NwDiagram.java
change_type: ModificationType.MODIFY
diff: @@ -150,11 +150,27 @@ public class NwDiagram extends UmlDiagram {
 		for (NStackable element : stack)
 			if (element instanceof Network)
 				return CommandExecutionResult.error("Cannot nest network");
+
+		if (networks.size() == 0 && groups.size() == 0)
+			eventuallyConnectAllStandaloneServersToHiddenNetwork();
+
 		final Network network = createNetwork(name);
 		stack.add(0, network);
 		return CommandExecutionResult.ok();
 	}
 
+	private void eventuallyConnectAllStandaloneServersToHiddenNetwork() {
+		Network first = null;
+		for (NServer server : servers.values())
+			if (server.isAlone()) {
+				if (first == null) {
+					first = createNetwork("");
+					first.goInvisible();
+				}
+				server.connectMeIfAlone(first);
+			}
+	}
+
 	public CommandExecutionResult closeSomething() {
 		if (initDone == false)
 			return errorNoInit();

programming_language: Java
filename: NServer.java
change_type: ModificationType.MODIFY
diff: @@ -112,6 +112,8 @@ public class NServer {
 	}
 
 	public void learnThisAddress(String address) {
+		if (address == null)
+			address = "";
 		for (Entry<Network, String> ent : connections.entrySet()) {
 			if (ent.getValue().length() == 0) {
 				connections.put(ent.getKey(), address);

programming_language: Java
filename: SURL.java
change_type: ModificationType.MODIFY
diff: @@ -250,7 +250,7 @@ public class SURL {
 	/**
 	 * Regex to remove the UserInfo part from a URL.
 	 */
-	private static final Pattern PATTERN_USERINFO = Pattern.compile("(^https?://)([-_0-9a-zA-Z]+@)([^@]*)");
+	private static final Pattern PATTERN_USERINFO = Pattern.compile("(^https?://)([-_0-9a-zA-Z]+@)([^@]*)$");
 
 	private static final ExecutorService EXE = Executors.newCachedThreadPool(new ThreadFactory() {
 		public Thread newThread(Runnable r) {
@@ -292,6 +292,9 @@ public class SURL {
 	}
 
 	private boolean forbiddenURL(String full) {
+		// Thanks to Agasthya Kasturi
+		if (full.contains("@"))
+			return true;
 		if (full.startsWith("https://") == false && full.startsWith("http://") == false)
 			return true;
 		if (full.matches("^https?://[-#.0-9:\\[\\]+]+/.*"))
@@ -305,6 +308,9 @@ public class SURL {
 
 	private boolean isInUrlAllowList() {
 		final String full = cleanPath(internal.toString());
+		// Thanks to Agasthya Kasturi
+		if (full.contains("@"))
+			return false;
 		for (String allow : getUrlAllowList())
 			if (full.startsWith(cleanPath(allow)))
 				return true;

programming_language: Java
filename: TFunctionImpl.java
change_type: ModificationType.MODIFY
diff: @@ -60,9 +60,9 @@ public class TFunctionImpl implements TFunction {
 	public TFunctionImpl(String functionName, List<TFunctionArgument> args, boolean unquoted,
 			TFunctionType functionType) {
 		final Set<String> names = new HashSet<>();
-		for (TFunctionArgument tmp : args) {
+		for (TFunctionArgument tmp : args)
 			names.add(tmp.getName());
-		}
+
 		this.signature = new TFunctionSignature(functionName, args.size(), names);
 		this.args = args;
 		this.unquoted = unquoted;
@@ -70,27 +70,26 @@ public class TFunctionImpl implements TFunction {
 	}
 
 	public boolean canCover(int nbArg, Set<String> namedArguments) {
-		for (String n : namedArguments) {
-			if (signature.getNamedArguments().contains(n) == false) {
+		for (String n : namedArguments)
+			if (signature.getNamedArguments().contains(n) == false)
 				return false;
-			}
-		}
-		if (nbArg > args.size()) {
+
+		if (nbArg > args.size())
 			return false;
-		}
+
 		assert nbArg <= args.size();
 		int neededArgument = 0;
 		for (TFunctionArgument arg : args) {
-			if (namedArguments.contains(arg.getName())) {
+			if (namedArguments.contains(arg.getName()))
 				continue;
-			}
-			if (arg.getOptionalDefaultValue() == null) {
+
+			if (arg.getOptionalDefaultValue() == null)
 				neededArgument++;
-			}
+
 		}
-		if (nbArg < neededArgument) {
+		if (nbArg < neededArgument)
 			return false;
-		}
+
 		assert nbArg >= neededArgument;
 		return true;
 	}
@@ -108,9 +107,9 @@ public class TFunctionImpl implements TFunction {
 			} else {
 				value = arg.getOptionalDefaultValue();
 			}
-			if (value == null) {
+			if (value == null)
 				throw new IllegalStateException();
-			}
+
 			result.put(arg.getName(), value);
 		}
 		return memory.forkFromGlobal(result);
@@ -125,11 +124,9 @@ public class TFunctionImpl implements TFunction {
 		body.add(s);
 		if (s.getType() == TLineType.RETURN) {
 			this.containsReturn = true;
-			if (functionType == TFunctionType.PROCEDURE) {
+			if (functionType == TFunctionType.PROCEDURE)
 				throw EaterExceptionLocated
 						.located("A procedure cannot have !return directive. Declare it as a function instead ?", s);
-				// this.functionType = TFunctionType.RETURN;
-			}
 		}
 	}
 
@@ -147,39 +144,39 @@ public class TFunctionImpl implements TFunction {
 
 	public void executeProcedureInternal(TContext context, TMemory memory, List<TValue> args, Map<String, TValue> named)
 			throws EaterException, EaterExceptionLocated {
-		if (functionType != TFunctionType.PROCEDURE && functionType != TFunctionType.LEGACY_DEFINELONG) {
+		if (functionType != TFunctionType.PROCEDURE && functionType != TFunctionType.LEGACY_DEFINELONG)
 			throw new IllegalStateException();
-		}
+
 		final TMemory copy = getNewMemory(memory, args, named);
 		context.executeLines(copy, body, TFunctionType.PROCEDURE, false);
 	}
 
 	public TValue executeReturnFunction(TContext context, TMemory memory, LineLocation location, List<TValue> args,
 			Map<String, TValue> named) throws EaterException, EaterExceptionLocated {
-		if (functionType == TFunctionType.LEGACY_DEFINE) {
+		if (functionType == TFunctionType.LEGACY_DEFINE)
 			return executeReturnLegacyDefine(location, context, memory, args);
-		}
-		if (functionType != TFunctionType.RETURN_FUNCTION) {
+
+		if (functionType != TFunctionType.RETURN_FUNCTION)
 			throw EaterException.unlocated("Illegal call here. Is there a return directive in your function?");
-		}
+
 		final TMemory copy = getNewMemory(memory, args, named);
 		final TValue result = context.executeLines(copy, body, TFunctionType.RETURN_FUNCTION, true);
-		if (result == null) {
+		if (result == null)
 			throw EaterException.unlocated("No return directive found in your function");
-		}
+
 		return result;
 	}
 
 	private TValue executeReturnLegacyDefine(LineLocation location, TContext context, TMemory memory, List<TValue> args)
 			throws EaterException, EaterExceptionLocated {
-		if (legacyDefinition == null) {
+		if (legacyDefinition == null)
 			throw new IllegalStateException();
-		}
+
 		final TMemory copy = getNewMemory(memory, args, Collections.<String, TValue>emptyMap());
 		final String tmp = context.applyFunctionsAndVariables(copy, location, legacyDefinition);
-		if (tmp == null) {
+		if (tmp == null)
 			return TValue.fromString("");
-		}
+
 		return TValue.fromString(tmp);
 		// eaterReturn.execute(context, copy);
 		// // System.err.println("s3=" + eaterReturn.getValue2());
@@ -211,9 +208,9 @@ public class TFunctionImpl implements TFunction {
 	}
 
 	public void finalizeEnddefinelong() {
-		if (functionType != TFunctionType.LEGACY_DEFINELONG) {
+		if (functionType != TFunctionType.LEGACY_DEFINELONG)
 			throw new UnsupportedOperationException();
-		}
+
 		if (body.size() == 1) {
 			this.functionType = TFunctionType.LEGACY_DEFINE;
 			this.legacyDefinition = body.get(0).getString();

programming_language: Java
filename: LicenseInfo.java
change_type: ModificationType.MODIFY
diff: @@ -108,27 +108,27 @@ public class LicenseInfo {
 
 	public static synchronized LicenseInfo retrieveNamedSlow() {
 		cache = LicenseInfo.NONE;
-		if (OptionFlags.ALLOW_INCLUDE == false) {
+		if (OptionFlags.ALLOW_INCLUDE == false)
 			return cache;
-		}
+
 		final String key = prefs.get("license", "");
 		if (key.length() > 0) {
 			cache = setIfValid(retrieveNamed(key), cache);
-			if (cache.isValid()) {
+			if (cache.isValid())
 				return cache;
-			}
+
 		}
 		for (SFile f : fileCandidates()) {
 			try {
 				if (f.exists() && f.canRead()) {
 					final LicenseInfo result = retrieve(f);
-					if (result == null) {
+					if (result == null)
 						return null;
-					}
+
 					cache = setIfValid(result, cache);
-					if (cache.isValid()) {
+					if (cache.isValid())
 						return cache;
-					}
+
 				}
 			} catch (IOException e) {
 				Log.info("Error " + e);
@@ -157,13 +157,13 @@ public class LicenseInfo {
 		}
 		try {
 			final byte[] s1 = PLSSignature.retrieveDistributorImageSignature();
-			if (SignatureUtils.toHexString(s1).equals(SignatureUtils.toHexString(licenseInfo.sha)) == false) {
+			if (SignatureUtils.toHexString(s1).equals(SignatureUtils.toHexString(licenseInfo.sha)) == false)
 				return null;
-			}
+
 			final InputStream dis = PSystemVersion.class.getResourceAsStream("/distributor.png");
-			if (dis == null) {
+			if (dis == null)
 				return null;
-			}
+
 			try {
 				final BufferedImage result = SImageIO.read(dis);
 				return result;
@@ -178,9 +178,9 @@ public class LicenseInfo {
 
 	public static LicenseInfo retrieveDistributor() {
 		final InputStream dis = PSystemVersion.class.getResourceAsStream("/distributor.txt");
-		if (dis == null) {
+		if (dis == null)
 			return null;
-		}
+
 		try {
 			final BufferedReader br = new BufferedReader(new InputStreamReader(dis));
 			final String licenseString = br.readLine();
@@ -188,11 +188,10 @@ public class LicenseInfo {
 			final LicenseInfo result = PLSSignature.retrieveDistributor(licenseString);
 			final Throwable creationPoint = new Throwable();
 			creationPoint.fillInStackTrace();
-			for (StackTraceElement ste : creationPoint.getStackTrace()) {
-				if (ste.toString().contains(result.context)) {
+			for (StackTraceElement ste : creationPoint.getStackTrace())
+				if (ste.toString().contains(result.context))
 					return result;
-				}
-			}
+
 			return null;
 		} catch (Exception e) {
 			Logme.error(e);
@@ -208,34 +207,34 @@ public class LicenseInfo {
 			if (s == null)
 				continue;
 			SFile dir = new SFile(s);
-			if (dir.isFile()) {
+			if (dir.isFile())
 				dir = dir.getParentFile();
-			}
-			if (dir != null && dir.isDirectory()) {
+
+			if (dir != null && dir.isDirectory())
 				result.add(dir.file("license.txt"));
-			}
+
 		}
 		return result;
 	}
 
 	private static LicenseInfo setIfValid(LicenseInfo value, LicenseInfo def) {
-		if (value.isValid() || def.isNone()) {
+		if (value.isValid() || def.isNone())
 			return value;
-		}
+
 		return def;
 	}
 
 	private static LicenseInfo retrieve(SFile f) throws IOException {
 		final BufferedReader br = f.openBufferedReader();
-		if (br == null) {
+		if (br == null)
 			return null;
-		}
+
 		try {
 			final String s = br.readLine();
 			final LicenseInfo result = retrieveNamed(s);
-			if (result != null) {
+			if (result != null)
 				Log.info("Reading license from " + f.getAbsolutePath());
-			}
+
 			return result;
 		} finally {
 			br.close();

programming_language: Java
filename: Version.java
change_type: ModificationType.MODIFY
diff: @@ -46,7 +46,7 @@ public class Version {
 
 	// Warning, "version" should be the same in gradle.properties and Version.java
 	// Any idea anyone how to magically synchronize those :-) ?
-	private static final String version = "1.2023.9beta3";
+	private static final String version = "1.2023.9beta4";
 
 	public static String versionString() {
 		return version;

programming_language: Java