diff --git a/README.md b/README.md index 30cd5de..5cb60ac 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ For adding a library only: com.instancify.scriptify core - 1.3.0-SNAPSHOT + 1.3.1-SNAPSHOT ``` @@ -26,12 +26,12 @@ For adding a library with JS for Rhino or GraalVM: com.instancify.scriptify script-js-rhino - 1.3.0-SNAPSHOT + 1.3.1-SNAPSHOT com.instancify.scriptify script-js-graalvm - 1.3.0-SNAPSHOT + 1.3.1-SNAPSHOT ``` ## Gradle @@ -45,11 +45,11 @@ maven { For adding a library only: ```groovy -implementation "com.instancify.scriptify:core:1.3.0-SNAPSHOT" +implementation "com.instancify.scriptify:core:1.3.1-SNAPSHOT" ``` For adding a library with JS for Rhino or GraalVM: ```groovy -implementation "com.instancify.scriptify:script-js-rhino:1.3.0-SNAPSHOT" -implementation "com.instancify.scriptify:script-js-graalvm:1.3.0-SNAPSHOT" +implementation "com.instancify.scriptify:script-js-rhino:1.3.1-SNAPSHOT" +implementation "com.instancify.scriptify:script-js-graalvm:1.3.1-SNAPSHOT" ``` \ No newline at end of file diff --git a/api/src/main/java/com/instancify/scriptify/api/script/Script.java b/api/src/main/java/com/instancify/scriptify/api/script/Script.java index 0240a0b..fb38b95 100644 --- a/api/src/main/java/com/instancify/scriptify/api/script/Script.java +++ b/api/src/main/java/com/instancify/scriptify/api/script/Script.java @@ -4,6 +4,7 @@ import com.instancify.scriptify.api.exception.ScriptFunctionException; import com.instancify.scriptify.api.script.constant.ScriptConstantManager; import com.instancify.scriptify.api.script.function.ScriptFunctionManager; +import com.instancify.scriptify.api.script.security.ScriptSecurityManager; /** * Defines the structure of a script that can be executed. @@ -44,6 +45,14 @@ public interface Script { */ void setConstantManager(ScriptConstantManager constantManager); + /** + * Retrieves the security manager associated with this script. + * + * @return The ScriptSecurityManager for this script + * @see ScriptSecurityManager + */ + ScriptSecurityManager getSecurityManager(); + /** * Evaluates and executes this script. * diff --git a/api/src/main/java/com/instancify/scriptify/api/script/security/ScriptSecurityManager.java b/api/src/main/java/com/instancify/scriptify/api/script/security/ScriptSecurityManager.java new file mode 100644 index 0000000..ed6c3bd --- /dev/null +++ b/api/src/main/java/com/instancify/scriptify/api/script/security/ScriptSecurityManager.java @@ -0,0 +1,52 @@ +package com.instancify.scriptify.api.script.security; + +import com.instancify.scriptify.api.script.constant.ScriptConstantManager; +import com.instancify.scriptify.api.script.security.exclude.SecurityExclude; + +import java.util.Set; + +public interface ScriptSecurityManager { + + /** + * Gets a current security mode. + * + * @return The boolean associated with security mode state + */ + boolean getSecurityMode(); + + /** + * Sets the current security mode. + * + * @param securityMode The boolean associated with security mode state + * @see ScriptConstantManager + */ + void setSecurityMode(boolean securityMode); + + /** + * Receives security path accessor. + * + * @return Security path accessor + */ + SecurityPathAccessor getPathAccessor(); + + /** + * Retrieves all existing exclusions for this script. + * + * @return Set with exclusions + */ + Set getExcludes(); + + /** + * Adds an exclusion for a path or package. + * + * @param exclude The exclusion to be added + */ + void addExclude(SecurityExclude exclude); + + /** + * Removes an exclusion for a path or package. + * + * @param exclude The exclusion to remove + */ + void removeExclude(SecurityExclude exclude); +} diff --git a/api/src/main/java/com/instancify/scriptify/api/script/security/SecurityClassAccessor.java b/api/src/main/java/com/instancify/scriptify/api/script/security/SecurityClassAccessor.java new file mode 100644 index 0000000..a8f4a26 --- /dev/null +++ b/api/src/main/java/com/instancify/scriptify/api/script/security/SecurityClassAccessor.java @@ -0,0 +1,23 @@ +package com.instancify.scriptify.api.script.security; + +import java.util.Set; + +/** + * Manages class access based on security constraints, ensuring only allowed classes can be used. + */ +public interface SecurityClassAccessor { + + /** + * Retrieves the set of class names that are allowed to be accessed or used. + * + * @return A set of strings representing the names of allowed classes + */ + Set getAllowedClasses(); + + /** + * Adds a class to the list of allowed classes, which can then be used or accessed. + * + * @param allowedClass The name of the class to be added to the allowed list + */ + void addAllowedClass(String allowedClass); +} \ No newline at end of file diff --git a/api/src/main/java/com/instancify/scriptify/api/script/security/SecurityPathAccessor.java b/api/src/main/java/com/instancify/scriptify/api/script/security/SecurityPathAccessor.java new file mode 100644 index 0000000..1a11b16 --- /dev/null +++ b/api/src/main/java/com/instancify/scriptify/api/script/security/SecurityPathAccessor.java @@ -0,0 +1,40 @@ +package com.instancify.scriptify.api.script.security; + +import java.nio.file.Path; + +/** + * Manages path access based on security constraints, ensuring only safe paths are accessible. + */ +public interface SecurityPathAccessor { + + /** + * Gets a base path for this accessor, which will be used for relative path calculations. + * + * @return The base path to set + */ + Path getBasePath(); + + /** + * Sets a new base path for this accessor, which will be used for relative path calculations. + * + * @param basePath The new base path to set + */ + void setBasePath(Path basePath); + + /** + * Returns a path that is safe to access according to security rules. If the path is not accessible, + * it returns a path relative to the base path with ':' characters removed to prevent potential path traversal attacks. + * + * @param path The path string to be checked and possibly modified + * @return A Path object representing the accessible path or a sanitized version if not accessible + */ + Path getAccessiblePath(String path); + + /** + * Checks if the given path is accessible based on the current security settings. + * + * @param path The path to check for access permission + * @return true if the path is accessible, false otherwise + */ + boolean isAccessible(String path); +} diff --git a/api/src/main/java/com/instancify/scriptify/api/script/security/exclude/ClassSecurityExclude.java b/api/src/main/java/com/instancify/scriptify/api/script/security/exclude/ClassSecurityExclude.java new file mode 100644 index 0000000..118c770 --- /dev/null +++ b/api/src/main/java/com/instancify/scriptify/api/script/security/exclude/ClassSecurityExclude.java @@ -0,0 +1,15 @@ +package com.instancify.scriptify.api.script.security.exclude; + +public class ClassSecurityExclude implements SecurityExclude { + + private final Class value; + + public ClassSecurityExclude(Class value) { + this.value = value; + } + + @Override + public String getValue() { + return value.getName(); + } +} diff --git a/api/src/main/java/com/instancify/scriptify/api/script/security/exclude/PathSecurityExclude.java b/api/src/main/java/com/instancify/scriptify/api/script/security/exclude/PathSecurityExclude.java new file mode 100644 index 0000000..0c5e516 --- /dev/null +++ b/api/src/main/java/com/instancify/scriptify/api/script/security/exclude/PathSecurityExclude.java @@ -0,0 +1,15 @@ +package com.instancify.scriptify.api.script.security.exclude; + +public class PathSecurityExclude implements SecurityExclude { + + private final String value; + + public PathSecurityExclude(String value) { + this.value = value; + } + + @Override + public String getValue() { + return value; + } +} diff --git a/api/src/main/java/com/instancify/scriptify/api/script/security/exclude/SecurityExclude.java b/api/src/main/java/com/instancify/scriptify/api/script/security/exclude/SecurityExclude.java new file mode 100644 index 0000000..1b14e79 --- /dev/null +++ b/api/src/main/java/com/instancify/scriptify/api/script/security/exclude/SecurityExclude.java @@ -0,0 +1,42 @@ +package com.instancify.scriptify.api.script.security.exclude; + +public interface SecurityExclude { + + /** + * Value to be added to the exclusion. + * + * @return The exclusion value + */ + String getValue(); + + /** + * Checks that the value of path or packet is excluded. + * + * @param value Path or package + * @return True if excluded, otherwise false + */ + default boolean isExcluded(String value) { + // Check that the path starts from the path specified in the exclusion + return value.startsWith(this.getValue()); + } + + /** + * Creates a new exclusion instance for the class. + * + * @param value A class that will be excluded + * @return A new exclusion instance for the class + */ + static ClassSecurityExclude ofClass(Class value) { + return new ClassSecurityExclude(value); + } + + /** + * Creates a new exclusion instance for the path. + * + * @param value A path that will be excluded + * @return A new exclusion instance for the path + */ + static PathSecurityExclude ofPath(String value) { + return new PathSecurityExclude(value); + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 260ca2a..e7a022c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,7 +12,7 @@ java { allprojects { group = "com.instancify.scriptify" - version = "1.3.1-SNAPSHOT" + version = "1.3.2-SNAPSHOT" } subprojects { diff --git a/core/src/main/java/com/instancify/scriptify/core/script/function/impl/file/ScriptFunctionWriteFile.java b/core/src/main/java/com/instancify/scriptify/core/script/function/impl/file/ScriptFunctionWriteFile.java index 69c881d..8b84678 100644 --- a/core/src/main/java/com/instancify/scriptify/core/script/function/impl/file/ScriptFunctionWriteFile.java +++ b/core/src/main/java/com/instancify/scriptify/core/script/function/impl/file/ScriptFunctionWriteFile.java @@ -10,7 +10,6 @@ import java.io.IOException; import java.nio.file.Files; -import java.nio.file.Path; /** * Represents a function to write the contents of a file @@ -27,7 +26,7 @@ public Object invoke(Script script, ScriptFunctionArgument[] args) throws Scr if (args.length == 2) { if (args[0].getValue() instanceof String filePath && args[1].getValue() instanceof String fileContent) { try { - return Files.writeString(Path.of(filePath), fileContent); + return Files.writeString(script.getSecurityManager().getPathAccessor().getAccessiblePath(filePath), fileContent); } catch (IOException e) { throw new ScriptFunctionException(e); } diff --git a/core/src/main/java/com/instancify/scriptify/core/script/function/impl/os/ScriptFunctionExecCommand.java b/core/src/main/java/com/instancify/scriptify/core/script/function/impl/os/ScriptFunctionExecCommand.java index 1a8c884..bb9cef3 100644 --- a/core/src/main/java/com/instancify/scriptify/core/script/function/impl/os/ScriptFunctionExecCommand.java +++ b/core/src/main/java/com/instancify/scriptify/core/script/function/impl/os/ScriptFunctionExecCommand.java @@ -31,23 +31,23 @@ public Object invoke(Script script, ScriptFunctionArgument[] args) throws Scr if (!(args[0].getValue() instanceof String input)) { throw new ScriptFunctionArgTypeException(String.class, args[0].getType()); } + try { Process process = Runtime.getRuntime().exec(input); - BufferedReader stdInput = new BufferedReader(new - InputStreamReader(process.getInputStream())); - BufferedReader stdError = new BufferedReader(new - InputStreamReader(process.getErrorStream())); - - String message = ""; - String buff = null; - while ((buff = stdInput.readLine()) != null) { - message += buff + "\n"; - } - while ((buff = stdError.readLine()) != null) { - message += buff + "\n"; - } - return message; + StringBuilder message = new StringBuilder(); + try (BufferedReader stdInput = new BufferedReader(new InputStreamReader(process.getInputStream())); + BufferedReader stdError = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + + String line; + while ((line = stdInput.readLine()) != null) { + message.append(line).append("\n"); + } + while ((line = stdError.readLine()) != null) { + message.append(line).append("\n"); + } + } + return message.toString(); } catch (IOException e) { throw new ScriptFunctionException(e); } diff --git a/core/src/main/java/com/instancify/scriptify/core/script/security/SecurityPathAccessorImpl.java b/core/src/main/java/com/instancify/scriptify/core/script/security/SecurityPathAccessorImpl.java new file mode 100644 index 0000000..3fd1bfd --- /dev/null +++ b/core/src/main/java/com/instancify/scriptify/core/script/security/SecurityPathAccessorImpl.java @@ -0,0 +1,97 @@ +package com.instancify.scriptify.core.script.security; + +import com.instancify.scriptify.api.script.security.ScriptSecurityManager; +import com.instancify.scriptify.api.script.security.SecurityPathAccessor; +import com.instancify.scriptify.api.script.security.exclude.PathSecurityExclude; +import com.instancify.scriptify.api.script.security.exclude.SecurityExclude; + +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Manages path access based on security constraints, ensuring only safe paths are accessible. + */ +public class SecurityPathAccessorImpl implements SecurityPathAccessor { + + private final ScriptSecurityManager securityManager; + private Path basePath; + + /** + * Constructs a SecurityPathAccessor with the default base path set to the current working directory. + * + * @param securityManager The security manager to check access permissions + */ + public SecurityPathAccessorImpl(ScriptSecurityManager securityManager) { + this(securityManager, Paths.get("").toAbsolutePath()); + } + + /** + * Constructs a SecurityPathAccessor with a specified base path for relative path calculations. + * + * @param securityManager The security manager to check access permissions + * @param basePath The base path from which relative paths are calculated + */ + public SecurityPathAccessorImpl(ScriptSecurityManager securityManager, Path basePath) { + this.securityManager = securityManager; + this.basePath = basePath; + } + + /** + * Gets a base path for this accessor, which will be used for relative path calculations. + * + * @return The base path to set + */ + @Override + public Path getBasePath() { + return basePath; + } + + /** + * Sets a new base path for this accessor, which will be used for relative path calculations. + * + * @param basePath The new base path to set + */ + @Override + public void setBasePath(Path basePath) { + this.basePath = basePath; + } + + /** + * Returns a path that is safe to access according to security rules. If the path is not accessible, + * it returns a path relative to the base path with ':' characters removed to prevent potential path traversal attacks. + * + * @param path The path string to be checked and possibly modified + * @return A Path object representing the accessible path or a sanitized version if not accessible + */ + @Override + public Path getAccessiblePath(String path) { + if (this.isAccessible(path)) { + return Path.of(path); + } + return Path.of(basePath.toString(), path.replaceAll(":", "")); + } + + /** + * Checks if the given path is accessible based on the current security settings. + * + * @param path The path to check for access permission + * @return true if the path is accessible, false otherwise + */ + @Override + public boolean isAccessible(String path) { + if (!securityManager.getSecurityMode()) { + return true; + } + + // Search all exclusions and check that the path is excluded + for (SecurityExclude exclude : securityManager.getExcludes()) { + if (exclude instanceof PathSecurityExclude) { + if (exclude.isExcluded(path)) { + return true; + } + } + } + + return false; + } +} diff --git a/core/src/main/java/com/instancify/scriptify/core/script/security/StandardSecurityManager.java b/core/src/main/java/com/instancify/scriptify/core/script/security/StandardSecurityManager.java new file mode 100644 index 0000000..20dbb66 --- /dev/null +++ b/core/src/main/java/com/instancify/scriptify/core/script/security/StandardSecurityManager.java @@ -0,0 +1,45 @@ +package com.instancify.scriptify.core.script.security; + +import com.instancify.scriptify.api.script.security.ScriptSecurityManager; +import com.instancify.scriptify.api.script.security.SecurityPathAccessor; +import com.instancify.scriptify.api.script.security.exclude.SecurityExclude; + +import java.util.HashSet; +import java.util.Set; + +public class StandardSecurityManager implements ScriptSecurityManager { + + private boolean securityMode; + private final Set excludes = new HashSet<>(); + private final SecurityPathAccessor pathAccessor = new SecurityPathAccessorImpl(this); + + @Override + public boolean getSecurityMode() { + return securityMode; + } + + @Override + public void setSecurityMode(boolean securityMode) { + this.securityMode = securityMode; + } + + @Override + public SecurityPathAccessor getPathAccessor() { + return pathAccessor; + } + + @Override + public Set getExcludes() { + return excludes; + } + + @Override + public void addExclude(SecurityExclude exclude) { + excludes.add(exclude); + } + + @Override + public void removeExclude(SecurityExclude exclude) { + excludes.remove(exclude); + } +} diff --git a/script-js-graalvm/src/main/java/com/instancify/scriptify/script/JsScript.java b/script-js-graalvm/src/main/java/com/instancify/scriptify/script/JsScript.java index 8801278..041ce37 100644 --- a/script-js-graalvm/src/main/java/com/instancify/scriptify/script/JsScript.java +++ b/script-js-graalvm/src/main/java/com/instancify/scriptify/script/JsScript.java @@ -6,14 +6,22 @@ import com.instancify.scriptify.api.script.constant.ScriptConstantManager; import com.instancify.scriptify.api.script.function.ScriptFunction; import com.instancify.scriptify.api.script.function.ScriptFunctionManager; +import com.instancify.scriptify.api.script.security.ScriptSecurityManager; +import com.instancify.scriptify.core.script.security.StandardSecurityManager; import org.graalvm.polyglot.*; public class JsScript implements Script { private final Context context = Context.create(); + private final ScriptSecurityManager securityManager = new StandardSecurityManager(); private ScriptFunctionManager functionManager; private ScriptConstantManager constantManager; + @Override + public ScriptSecurityManager getSecurityManager() { + return securityManager; + } + @Override public ScriptFunctionManager getFunctionManager() { return functionManager; diff --git a/script-js-rhino/src/main/java/com/instancify/scriptify/script/JsScript.java b/script-js-rhino/src/main/java/com/instancify/scriptify/script/JsScript.java index 5175383..031b80f 100644 --- a/script-js-rhino/src/main/java/com/instancify/scriptify/script/JsScript.java +++ b/script-js-rhino/src/main/java/com/instancify/scriptify/script/JsScript.java @@ -6,16 +6,25 @@ import com.instancify.scriptify.api.script.constant.ScriptConstantManager; import com.instancify.scriptify.api.script.function.ScriptFunction; import com.instancify.scriptify.api.script.function.ScriptFunctionManager; +import com.instancify.scriptify.api.script.security.ScriptSecurityManager; +import com.instancify.scriptify.api.script.security.exclude.ClassSecurityExclude; +import com.instancify.scriptify.api.script.security.exclude.SecurityExclude; +import com.instancify.scriptify.core.script.security.StandardSecurityManager; import org.mozilla.javascript.Context; import org.mozilla.javascript.ScriptableObject; public class JsScript implements Script { private final Context context = Context.enter(); - private final ScriptableObject scope = context.initStandardObjects(); + private final ScriptSecurityManager securityManager = new StandardSecurityManager(); private ScriptFunctionManager functionManager; private ScriptConstantManager constantManager; + @Override + public ScriptSecurityManager getSecurityManager() { + return securityManager; + } + @Override public ScriptFunctionManager getFunctionManager() { return functionManager; @@ -38,6 +47,20 @@ public void setConstantManager(ScriptConstantManager constantManager) { @Override public Object eval(String script) throws ScriptException { + ScriptableObject scope = context.initStandardObjects(); + + // If security mode is enabled, search all exclusions + // and add the classes that were excluded to JsSafeClassShutter + if (securityManager.getSecurityMode()) { + JsSecurityClassAccessor classAccessor = new JsSecurityClassAccessor(); + for (SecurityExclude exclude : securityManager.getExcludes()) { + if (exclude instanceof ClassSecurityExclude classExclude) { + classAccessor.addAllowedClass(classExclude.getValue()); + } + } + context.setClassShutter(classAccessor); + } + if (functionManager != null) { for (ScriptFunction function : functionManager.getFunctions().values()) { scope.put(function.getName(), scope, new JsFunction(this, function)); @@ -54,6 +77,8 @@ public Object eval(String script) throws ScriptException { return context.evaluateString(scope, script, null, 1, null); } catch (Exception e) { throw new ScriptException(e); + } finally { + context.close(); } } } diff --git a/script-js-rhino/src/main/java/com/instancify/scriptify/script/JsSecurityClassAccessor.java b/script-js-rhino/src/main/java/com/instancify/scriptify/script/JsSecurityClassAccessor.java new file mode 100644 index 0000000..f94ed7f --- /dev/null +++ b/script-js-rhino/src/main/java/com/instancify/scriptify/script/JsSecurityClassAccessor.java @@ -0,0 +1,32 @@ +package com.instancify.scriptify.script; + +import com.instancify.scriptify.api.script.security.SecurityClassAccessor; +import org.mozilla.javascript.ClassShutter; +import org.mozilla.javascript.EcmaError; + +import java.util.HashSet; +import java.util.Set; + +public class JsSecurityClassAccessor implements ClassShutter, SecurityClassAccessor { + + private final Set allowedClasses = new HashSet<>(); + + public JsSecurityClassAccessor() { + this.allowedClasses.add(EcmaError.class.getName()); + } + + @Override + public Set getAllowedClasses() { + return allowedClasses; + } + + @Override + public void addAllowedClass(String allowedClass) { + this.allowedClasses.add(allowedClass); + } + + @Override + public boolean visibleToScripts(String fullClassName) { + return this.allowedClasses.contains(fullClassName); + } +}