diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml index 92fafa00de2c..b0fd23ea5128 100644 --- a/conf/cassandra.yaml +++ b/conf/cassandra.yaml @@ -2019,8 +2019,9 @@ trace_type_repair_ttl: 7d # If unset, all GC Pauses greater than gc_log_threshold will log at # INFO level -# UDFs (user defined functions) are disabled by default. -# As of Cassandra 3.0 there is a sandbox in place that should prevent execution of evil code. +# Cassandra disables user-defined functions (UDFs) by default. +# Cassandra restricts UDF bytecode and class loading within its JVM. +# These restrictions do not provide complete process isolation. user_defined_functions_enabled: false # Triggers are enabled by default. diff --git a/conf/jvm-server.options b/conf/jvm-server.options index 4a397fbfb905..f661b8277e7d 100644 --- a/conf/jvm-server.options +++ b/conf/jvm-server.options @@ -96,6 +96,15 @@ # Set the default location for the trigger JARs. (Default: conf/triggers) #-Dcassandra.triggers_dir=directory +# Select the sandbox for user-defined functions (UDFs). +# Valid values are auto (default), sandbox, and securitymanager. +# The auto setting uses the Java security manager based sandbox before Java Development Kit (JDK) 24. +# It uses the bytecode sandbox on JDK 24 and later. +# The sandbox setting uses the bytecode sandbox on every supported JDK. +# The securitymanager setting requires the Java security manager based sandbox. +# Startup fails if the runtime cannot install the security manager. +#-Dcassandra.udf.security_mechanism=auto + # For testing new compaction and compression strategies. It allows you to experiment with different # strategies and benchmark write performance differences without affecting the production workload. #-Dcassandra.write_survey=true diff --git a/doc/modules/cassandra/pages/developing/cql/functions.adoc b/doc/modules/cassandra/pages/developing/cql/functions.adoc index cb3e28b220e4..a1e9661a38a8 100644 --- a/doc/modules/cassandra/pages/developing/cql/functions.adoc +++ b/doc/modules/cassandra/pages/developing/cql/functions.adoc @@ -588,6 +588,61 @@ include::cassandra:example$JAVA/udf_imports.java[] Please note, that these convenience imports are not available for script UDFs. +==== UDF restrictions + +UDFs run inside the Cassandra Java Virtual Machine (JVM). +Cassandra checks UDF bytecode. +It restricts the classes that a UDF can load. +These controls do not provide complete process isolation. +A UDF can consume processor time or heap memory. +Grant permission to create functions only to trusted roles. + +Set the JVM system property `cassandra.udf.security_mechanism` before node startup. +The default value is `auto`. + +[cols="1,3",options="header"] +|=== +|Value |Behavior +|`auto` |Cassandra uses the Java security manager based sandbox before Java Development Kit (JDK) 24. +It uses the bytecode sandbox on JDK 24 and later. +|`sandbox` |Cassandra uses the bytecode sandbox on every supported JDK. +It skips security manager and security policy installation. +|`securitymanager` |Cassandra requires the Java security manager based sandbox. +Startup fails if the runtime cannot install the security manager. +|=== + +An invalid value causes a configuration error. +For example, add `-Dcassandra.udf.security_mechanism=sandbox` to the JVM options to select the sandbox. +Use a JDK version that Cassandra supports. + +The bytecode sandbox rejects restricted calls when Cassandra creates a function. +It blocks system property access and environment access. +It blocks process exit and native library loading. +It blocks changes to standard input and output. +It also blocks changes to the default process locale and time zone. +It blocks indirect property access through `Integer.getInteger`, `Long.getLong`, and `Boolean.getBoolean`. +The class loader blocks file access and process control. +It also blocks reflection and method handles. +The verifier rejects `ClassLoader` calls. +It also rejects calls through `Module` and `ModuleLayer`. +It rejects `Class.getModule`. +A function cannot declare additional classes. + +UDF threads can execute calls to `System.nanoTime`, `System.currentTimeMillis`, and `System.arraycopy`. +Cassandra applies warning and failure timeouts when `user_defined_functions_threads_enabled=true`. +The `user_function_timeout_policy` setting controls the response to a failure timeout. + +To permit restricted `System` access, use these settings: + +. Set `allow_insecure_udfs=true` in `cassandra.yaml`. +. Set `user_defined_functions_threads_enabled=false` in `cassandra.yaml`. +. Set `allow_extra_insecure_udfs=true` in `cassandra.yaml`. + +This configuration permits restricted `System` method calls and indirect property access. +With UDF threads enabled, the selected sandbox still enforces its restrictions. +The class loader restrictions and the base bytecode checks still apply. +Cassandra applies execution timeouts only when UDF threads are enabled. + [[create-function-statement]] === CREATE FUNCTION statement diff --git a/src/java/org/apache/cassandra/audit/AuditLogManager.java b/src/java/org/apache/cassandra/audit/AuditLogManager.java index 00f086d080c3..8a27fb8341f0 100644 --- a/src/java/org/apache/cassandra/audit/AuditLogManager.java +++ b/src/java/org/apache/cassandra/audit/AuditLogManager.java @@ -22,8 +22,6 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Proxy; -import java.security.AccessControlContext; -import java.security.AccessController; import java.security.Principal; import java.util.Collections; import java.util.List; @@ -57,6 +55,7 @@ import org.apache.cassandra.exceptions.PreparedQueryNotFoundException; import org.apache.cassandra.exceptions.SyntaxException; import org.apache.cassandra.exceptions.UnauthorizedException; +import org.apache.cassandra.security.JMXSubjects; import org.apache.cassandra.service.QueryState; import org.apache.cassandra.transport.Message; import org.apache.cassandra.transport.messages.ResultMessage; @@ -493,8 +492,7 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl return null; } - AccessControlContext acc = AccessController.getContext(); - Subject subject = Subject.getSubject(acc); + Subject subject = JMXSubjects.current(); try { diff --git a/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java b/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java index 183050b4c467..e32f4eaffa45 100644 --- a/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java +++ b/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java @@ -21,8 +21,6 @@ import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; -import java.security.AccessControlContext; -import java.security.AccessController; import java.security.Principal; import java.util.Collections; import java.util.Set; @@ -53,6 +51,7 @@ import org.apache.cassandra.auth.RoleResource; import org.apache.cassandra.auth.Roles; import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.security.JMXSubjects; import org.apache.cassandra.service.StorageService; import org.apache.cassandra.utils.JmxInvocationListener; import org.apache.cassandra.utils.MBeanWrapper; @@ -161,9 +160,8 @@ public Object invoke(Object proxy, Method method, Object[] args) { String methodName = method.getName(); - // Retrieve Subject from current AccessControlContext - AccessControlContext acc = AccessController.getContext(); - Subject subject = Subject.getSubject(acc); + // Retrieve the Subject for the current JMX invocation. + Subject subject = JMXSubjects.current(); try { diff --git a/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java b/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java index 61d297632258..96114dd1d451 100644 --- a/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java +++ b/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java @@ -734,6 +734,17 @@ public enum CassandraRelevantProperties UCS_SURVIVAL_FACTOR("unified_compaction.survival_factor", "1"), UCS_TARGET_SSTABLE_SIZE("unified_compaction.target_sstable_size", "1GiB"), UDF_EXECUTOR_THREAD_KEEPALIVE_MS("cassandra.udf_executor_thread_keepalive_ms", "30000"), + /** + * Selects the user-defined function (UDF) sandbox mechanism. + * + */ + UDF_SECURITY_MECHANISM("cassandra.udf.security_mechanism", "auto"), UNSAFE_SYSTEM("cassandra.unsafesystem"), /** User's home directory. */ USER_HOME("user.home"), diff --git a/src/java/org/apache/cassandra/config/Config.java b/src/java/org/apache/cassandra/config/Config.java index d032b7039c95..f721e6739aa7 100644 --- a/src/java/org/apache/cassandra/config/Config.java +++ b/src/java/org/apache/cassandra/config/Config.java @@ -724,8 +724,8 @@ public static class SSTableConfig public volatile boolean use_statements_enabled = true; /** - * Optionally disable asynchronous UDF execution. - * Disabling asynchronous UDF execution also implicitly disables the security-manager! + * Controls asynchronous execution of user-defined functions (UDFs). + * If Cassandra uses the security manager, disabling asynchronous execution disables its UDF execution checks. * By default, async UDF execution is enabled to be able to detect UDFs that run too long / forever and be * able to fail fast - i.e. stop the Cassandra daemon, which is currently the only appropriate approach to * "tell" a user that there's something really wrong with the UDF. @@ -745,7 +745,9 @@ public static class SSTableConfig public boolean allow_insecure_udfs = false; /** - * Set this to allow UDFs accessing java.lang.System.* methods, which basically allows UDFs to execute any arbitrary code on the system. + * Permits restricted System method calls and indirect property access only when UDF threads are disabled. + * With UDF threads enabled, the selected sandbox still enforces its restrictions. + * Class loader restrictions and base bytecode checks still apply. */ public boolean allow_extra_insecure_udfs = false; @@ -1650,4 +1652,4 @@ public enum CQLStartTime * 6.0 and later. */ public volatile boolean gossip_quarantine_disabled = false; -} \ No newline at end of file +} diff --git a/src/java/org/apache/cassandra/cql3/functions/JavaBasedUDFunction.java b/src/java/org/apache/cassandra/cql3/functions/JavaBasedUDFunction.java index f9ccb1dbe4ad..7664dfa3356d 100644 --- a/src/java/org/apache/cassandra/cql3/functions/JavaBasedUDFunction.java +++ b/src/java/org/apache/cassandra/cql3/functions/JavaBasedUDFunction.java @@ -77,6 +77,7 @@ import org.slf4j.LoggerFactory; import org.apache.cassandra.concurrent.NamedThreadFactory; +import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.cql3.ColumnIdentifier; import org.apache.cassandra.db.marshal.AbstractType; import org.apache.cassandra.exceptions.InvalidRequestException; @@ -93,8 +94,8 @@ public final class JavaBasedUDFunction extends UDFunction private static final AtomicInteger classSequence = new AtomicInteger(); - // use a JVM standard ExecutorService as ExecutorPlus references internal - // classes, which triggers AccessControlException from the UDF sandbox + // Use the standard ExecutorService to avoid references to Cassandra executor classes. + // Those references can cause AccessControlException in the user-defined function (UDF) sandbox. private static final UDFExecutorService executor = new UDFExecutorService(new NamedThreadFactory("UserDefinedFunctions", Thread.MIN_PRIORITY, @@ -104,8 +105,12 @@ public final class JavaBasedUDFunction extends UDFunction private static final EcjTargetClassLoader targetClassLoader = new EcjTargetClassLoader(); + // These base checks apply to every UDF configuration. private static final UDFByteCodeVerifier udfByteCodeVerifier = new UDFByteCodeVerifier(); + // Also rejects restricted System method calls and indirect property access. + private static final UDFByteCodeVerifier udfByteCodeVerifierSandbox = new UDFByteCodeVerifier(); + private static final ProtectionDomain protectionDomain; private static final IErrorHandlingPolicy errorHandlingPolicy = DefaultErrorHandlingPolicies.proceedWithAllProblems(); @@ -121,38 +126,33 @@ public final class JavaBasedUDFunction extends UDFunction static { - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/Class", "forName"); - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/Class", "getClassLoader"); - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/Class", "getResource"); - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/Class", "getResourceAsStream"); - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/ClassLoader", "clearAssertionStatus"); - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/ClassLoader", "getResource"); - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/ClassLoader", "getResourceAsStream"); - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/ClassLoader", "getResources"); - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/ClassLoader", "getSystemClassLoader"); - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/ClassLoader", "getSystemResource"); - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/ClassLoader", "getSystemResourceAsStream"); - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/ClassLoader", "getSystemResources"); - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/ClassLoader", "loadClass"); - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/ClassLoader", "setClassAssertionStatus"); - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/ClassLoader", "setDefaultAssertionStatus"); - udfByteCodeVerifier.addDisallowedMethodCall("java/lang/ClassLoader", "setPackageAssertionStatus"); - udfByteCodeVerifier.addDisallowedMethodCall("java/nio/ByteBuffer", "allocateDirect"); - for (String ia : new String[]{"java/net/InetAddress", "java/net/Inet4Address", "java/net/Inet6Address"}) - { - // static method, probably performing DNS lookups (despite SecurityManager) - udfByteCodeVerifier.addDisallowedMethodCall(ia, "getByAddress"); - udfByteCodeVerifier.addDisallowedMethodCall(ia, "getAllByName"); - udfByteCodeVerifier.addDisallowedMethodCall(ia, "getByName"); - udfByteCodeVerifier.addDisallowedMethodCall(ia, "getLocalHost"); - // instance methods, probably performing DNS lookups (despite SecurityManager) - udfByteCodeVerifier.addDisallowedMethodCall(ia, "getHostName"); - udfByteCodeVerifier.addDisallowedMethodCall(ia, "getCanonicalHostName"); - // ICMP PING - udfByteCodeVerifier.addDisallowedMethodCall(ia, "isReachable"); - } - udfByteCodeVerifier.addDisallowedClass("java/net/NetworkInterface"); - udfByteCodeVerifier.addDisallowedClass("java/net/SocketException"); + // Configure the rules shared by both verifiers. + configureBaseDisallowed(udfByteCodeVerifier); + configureBaseDisallowed(udfByteCodeVerifierSandbox); + + // The sandbox verifier rejects restricted System methods when Cassandra creates a function. + // Threaded UDFs may use System.nanoTime, System.currentTimeMillis, and System.arraycopy. + // The class loader blocks reflection and method handles. + for (String m : new String[]{ "exit", "setSecurityManager", "getSecurityManager", + "setProperty", "getProperty", "getProperties", "setProperties", + "clearProperty", "getenv", "load", "loadLibrary", + "setIn", "setOut", "setErr", "inheritedChannel", "console", + "getLogger" }) + udfByteCodeVerifierSandbox.addDisallowedMethodCall("java/lang/System", m); + + // These methods read system properties through System.getProperty. + udfByteCodeVerifierSandbox.addDisallowedMethodCall("java/lang/Integer", "getInteger"); + udfByteCodeVerifierSandbox.addDisallowedMethodCall("java/lang/Long", "getLong"); + udfByteCodeVerifierSandbox.addDisallowedMethodCall("java/lang/Boolean", "getBoolean"); + + // These setters previously required property-write permissions. + udfByteCodeVerifierSandbox.addDisallowedMethodCall("java/util/Locale", "setDefault"); + udfByteCodeVerifierSandbox.addDisallowedMethodCall("java/util/TimeZone", "setDefault"); + udfByteCodeVerifierSandbox.addDisallowedMethodCall("java/util/SimpleTimeZone", "setDefault"); + + // LoggerFinder.getLoggerFinder requires RuntimePermission "loggerFinder" with a SecurityManager. + // The sandbox verifier blocks both logger lookup methods. + udfByteCodeVerifierSandbox.addDisallowedMethodCall("java/lang/System$LoggerFinder", "getLoggerFinder"); Map settings = new HashMap<>(); settings.put(CompilerOptions.OPTION_LineNumberAttribute, @@ -204,6 +204,57 @@ protected URLConnection openConnection(URL u) protectionDomain = new ProtectionDomain(codeSource, ThreadAwareSecurityManager.noPermissions, targetClassLoader, null); } + /** Adds the bytecode checks for every UDF configuration. */ + private static void configureBaseDisallowed(UDFByteCodeVerifier verifier) + { + verifier.addDisallowedMethodCall("java/lang/Class", "forName"); + verifier.addDisallowedMethodCall("java/lang/Class", "getClassLoader"); + verifier.addDisallowedMethodCall("java/lang/Class", "getResource"); + verifier.addDisallowedMethodCall("java/lang/Class", "getResourceAsStream"); + // Class.getModule exposes Module methods that return a class loader or a resource stream. + // Class references Module, so the class loader must resolve Module. + // The verifier blocks calls through Module and ModuleLayer. + verifier.addDisallowedMethodCall("java/lang/Class", "getModule"); + verifier.addDisallowedClass("java/lang/Module"); + verifier.addDisallowedClass("java/lang/ModuleLayer"); + // Reject all ClassLoader calls. + verifier.addDisallowedClass("java/lang/ClassLoader"); + verifier.addDisallowedMethodCall("java/nio/ByteBuffer", "allocateDirect"); + for (String ia : new String[]{"java/net/InetAddress", "java/net/Inet4Address", "java/net/Inet6Address"}) + { + // These static methods create address objects or query the Domain Name System (DNS). + verifier.addDisallowedMethodCall(ia, "getByAddress"); + verifier.addDisallowedMethodCall(ia, "getAllByName"); + verifier.addDisallowedMethodCall(ia, "getByName"); + verifier.addDisallowedMethodCall(ia, "getLocalHost"); + // These instance methods can query DNS. + verifier.addDisallowedMethodCall(ia, "getHostName"); + verifier.addDisallowedMethodCall(ia, "getCanonicalHostName"); + // This method tests whether a host is reachable. + verifier.addDisallowedMethodCall(ia, "isReachable"); + } + verifier.addDisallowedClass("java/net/NetworkInterface"); + verifier.addDisallowedClass("java/net/SocketException"); + } + + /** Selects the verifier for the configured sandbox and execution mode. */ + private static UDFByteCodeVerifier verifierFor() + { + boolean useSecurityManager = ThreadAwareSecurityManager.useSecurityManager(); + boolean useUdfThreads = DatabaseDescriptor.enableUserDefinedFunctionsThreads(); + + // Secured UDF threads enforce System permissions at execution time. + if (useSecurityManager && useUdfThreads) + return udfByteCodeVerifier; + + // The explicit insecure setting permits restricted System method calls during synchronous execution. + if (!useUdfThreads && DatabaseDescriptor.allowExtraInsecureUDFs()) + return udfByteCodeVerifier; + + // Synchronous UDFs do not run on a secured thread, even if a security manager is installed. + return udfByteCodeVerifierSandbox; + } + private final JavaUDF javaUDF; private static final Pattern patternJavaDriver = Pattern.compile("com\\.datastax\\.driver\\.core\\."); @@ -316,8 +367,12 @@ protected URLConnection openConnection(URL u) throw new InvalidRequestException("Java source compilation failed:\n" + problems); } - // Verify the UDF bytecode against use of probably dangerous code - Set errors = udfByteCodeVerifier.verify(targetClassName, targetClassLoader.classData(targetClassName)); + // The verifier inspects one class file. Reject UDFs that declare another class. + if (compilationUnit.emittedClassFileCount != 1) + throw new InvalidRequestException("Java UDF validation failed: the function must not declare additional classes"); + + // Select the verifier from the current security mechanism and UDF configuration. + Set errors = verifierFor().verify(targetClassName, targetClassLoader.classData(targetClassName)); String validDeclare = "not allowed method declared: " + executeInternalName + '('; for (Iterator i = errors.iterator(); i.hasNext();) { @@ -517,6 +572,7 @@ private static StringBuilder appendGetMethodName(StringBuilder code, UDFDataType static final class EcjCompilationUnit implements ICompilationUnit, ICompilerRequestor, INameEnvironment { List problemList; + int emittedClassFileCount; private final String className; private final char[] sourceCode; @@ -602,6 +658,7 @@ public void acceptResult(CompilationResult result) else { ClassFile[] classFiles = result.getClassFiles(); + emittedClassFileCount += classFiles.length; for (ClassFile classFile : classFiles) targetClassLoader.addClass(className, classFile.getBytes()); } diff --git a/src/java/org/apache/cassandra/cql3/functions/UDFunction.java b/src/java/org/apache/cassandra/cql3/functions/UDFunction.java index 71d51c09c9e6..68e067f7951d 100644 --- a/src/java/org/apache/cassandra/cql3/functions/UDFunction.java +++ b/src/java/org/apache/cassandra/cql3/functions/UDFunction.java @@ -152,6 +152,13 @@ public abstract class UDFunction extends UserFunction implements ScalarFunction "java/lang/Thread.class", "java/lang/ThreadGroup.class", "java/lang/ThreadLocal.class", + // Block classes under allowed package prefixes that expose restricted operations. + // The class loader must resolve Module because Class references it. + // The bytecode verifier blocks Module and ModuleLayer calls. + "java/lang/ProcessHandle", // Provides process access on Java Development Kit (JDK) 9 and later. + "java/lang/StackWalker", // JDK 9 stack and class access + "java/lang/foreign/", // JDK 22 native calls and memory access + "java/lang/classfile/", // JDK 24 class-file generation and parsing "java/lang/instrument/", "java/lang/invoke/", "java/lang/management/", diff --git a/src/java/org/apache/cassandra/security/JMXSubjects.java b/src/java/org/apache/cassandra/security/JMXSubjects.java new file mode 100644 index 000000000000..511bee38616a --- /dev/null +++ b/src/java/org/apache/cassandra/security/JMXSubjects.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.security; + +import java.lang.reflect.Method; +import java.security.AccessController; + +import javax.security.auth.Subject; + +/** + * Gets the {@link Subject} for the current Java Management Extensions (JMX) call. + *

+ * Java Development Kit (JDK) 11, 17, and 21 store the subject in the {@link java.security.AccessControlContext}. + * Cassandra reads it with {@code Subject.getSubject}. + *

+ * On JDK 24 and later, Cassandra calls {@code Subject.current}. + * It uses reflection to keep compilation compatible with JDK 11. + */ +public final class JMXSubjects +{ + private static final Method SUBJECT_CURRENT = subjectCurrentMethod(); + + private JMXSubjects() + { + } + + private static Method subjectCurrentMethod() + { + try + { + return Subject.class.getMethod("current"); + } + catch (NoSuchMethodException e) + { + return null; // Subject.current is available on JDK 18 and later. + } + } + + /** + * @return the {@link Subject} associated with the current JMX invocation, or {@code null} if there is none. + */ + @SuppressWarnings({ "deprecation", "removal" }) + public static Subject current() + { + if (ThreadAwareSecurityManager.isSecurityManagerSupported()) + return Subject.getSubject(AccessController.getContext()); + + return currentSubject(); + } + + static Subject currentSubject() + { + if (SUBJECT_CURRENT == null) + throw new IllegalStateException("Subject.current() is unavailable but a SecurityManager is not supported on this JVM"); + + try + { + return (Subject) SUBJECT_CURRENT.invoke(null); + } + catch (ReflectiveOperationException e) + { + throw new RuntimeException("Failed to invoke Subject.current()", e); + } + } +} diff --git a/src/java/org/apache/cassandra/security/ThreadAwareSecurityManager.java b/src/java/org/apache/cassandra/security/ThreadAwareSecurityManager.java index 8639b46ffb3f..b23d7d946a92 100644 --- a/src/java/org/apache/cassandra/security/ThreadAwareSecurityManager.java +++ b/src/java/org/apache/cassandra/security/ThreadAwareSecurityManager.java @@ -34,10 +34,13 @@ import org.slf4j.LoggerFactory; import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.exceptions.ConfigurationException; import org.apache.cassandra.utils.logging.LoggingSupportFactory; import io.netty.util.concurrent.FastThreadLocal; +import static org.apache.cassandra.config.CassandraRelevantProperties.UDF_SECURITY_MECHANISM; + /** * Custom {@link SecurityManager} and {@link Policy} implementation that only performs access checks * if explicitly enabled. @@ -80,24 +83,86 @@ public Enumeration elements() private static volatile boolean installed; - public static void install() + /** + * Returns whether the running Java Development Kit (JDK) supports {@link SecurityManager} installation. + * On JDK 24 and later, {@code System.setSecurityManager} throws {@link UnsupportedOperationException}. + */ + public static boolean isSecurityManagerSupported() + { + return Runtime.version().feature() < 24; + } + + /** + * Returns whether the configured user-defined function (UDF) sandbox uses a {@link SecurityManager}. + * {@code auto} selects it before JDK 24. + * {@code sandbox} disables it. + * {@code securitymanager} requires it. + * With {@code securitymanager}, {@link #install()} fails on JDK 24 and later. + */ + public static boolean useSecurityManager() + { + return useSecurityManager(UDF_SECURITY_MECHANISM.getString(), Runtime.version().feature()); + } + + static boolean useSecurityManager(String value, int javaVersion) + { + String mechanism = value.trim(); + if (mechanism.equalsIgnoreCase("securitymanager")) + return true; + if (mechanism.equalsIgnoreCase("sandbox")) + return false; + if (mechanism.equalsIgnoreCase("auto")) + return javaVersion < 24; + throw new ConfigurationException(String.format("Invalid value '%s' for %s; expected one of: auto, securitymanager, sandbox", + value, UDF_SECURITY_MECHANISM.getKey())); + } + + public static synchronized void install() { + boolean useSecurityManager = useSecurityManager(); + if (!useSecurityManager) + { + // The byte-code sandbox does not require SecurityManager installation. + logger.info("Using the SecurityManager-free UDF sandbox (Java {}, {}={}).", + Runtime.version().feature(), UDF_SECURITY_MECHANISM.getKey(), UDF_SECURITY_MECHANISM.getString()); + } + + if (useSecurityManager && !isSecurityManagerSupported()) + { + // Reject an unsupported SecurityManager request before UDFs can run. + throw new ConfigurationException(String.format("%s=securitymanager but a SecurityManager cannot be installed on Java %d. " + + "Use 'auto' or 'sandbox' to use the SecurityManager-free UDF sandbox.", + UDF_SECURITY_MECHANISM.getKey(), Runtime.version().feature())); + } + if (installed) return; - // this line is needed - we need to make sure AccessControlException is loaded before we install this SM - // otherwise we may get into stackoverflow when javax.security is not allowed package, and ACE is tried to be - // loaded when it is going to be thrown from SM (class loader triggers SM to verify javax.security, - // it recognizes it as not allowed and attempts to throw it...) - //noinspection PlaceholderCountMatchesArgumentCount - logger.trace("Initialized thread aware security manager", AccessControlException.class.getName()); + if (useSecurityManager) + { + // Load AccessControlException before installing the security manager. + // Loading it during a permission check can cause recursion. + //noinspection PlaceholderCountMatchesArgumentCount + logger.trace("Initialized thread aware security manager", AccessControlException.class.getName()); - System.setSecurityManager(new ThreadAwareSecurityManager()); + try + { + installLegacyPolicy(); + System.setSecurityManager(new ThreadAwareSecurityManager()); + } + catch (UnsupportedOperationException | SecurityException e) + { + throw new ConfigurationException("Cannot install the UDF security manager. Set " + + UDF_SECURITY_MECHANISM.getKey() + "=sandbox to use the bytecode sandbox.", e); + } + } LoggingSupportFactory.getLoggingSupport().onStartup(); installed = true; } - static + /** Installs the policy only when the selected mechanism requires a security manager. */ + @SuppressWarnings("removal") + private static void installLegacyPolicy() { // // Use own security policy to be easier (and faster) since the C* has no fine grained permissions. diff --git a/test/unit/org/apache/cassandra/audit/AuditLoggerTest.java b/test/unit/org/apache/cassandra/audit/AuditLoggerTest.java index a7cf62504d5d..89567b686f5f 100644 --- a/test/unit/org/apache/cassandra/audit/AuditLoggerTest.java +++ b/test/unit/org/apache/cassandra/audit/AuditLoggerTest.java @@ -24,16 +24,21 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.rmi.server.RMISocketFactory; +import java.security.PrivilegedAction; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import javax.management.InstanceNotFoundException; import javax.management.JMX; import javax.management.MBeanServerConnection; +import javax.management.MBeanServerFactory; import javax.management.ObjectName; import javax.management.remote.JMXConnector; import javax.management.remote.JMXConnectorFactory; import javax.management.remote.JMXServiceURL; +import javax.management.remote.MBeanServerForwarder; +import javax.security.auth.Subject; import com.datastax.driver.core.BatchStatement; import com.datastax.driver.core.PreparedStatement; @@ -55,6 +60,7 @@ import org.apache.cassandra.auth.AuthEvents; import org.apache.cassandra.auth.AuthenticatedUser; +import org.apache.cassandra.auth.CassandraPrincipal; import org.apache.cassandra.auth.IAuthorizer; import org.apache.cassandra.auth.JMXResource; import org.apache.cassandra.auth.Permission; @@ -840,6 +846,47 @@ public void testJMXArchiveCommand() throws IOException assertEquals("/xyz/not/null", AuditLogManager.instance.getAuditLogOptions().archive_command); } + @Test + public void testJMXHandlerSubject() throws Exception + { + Subject authenticated = new Subject(); + authenticated.getPrincipals().add(new CassandraPrincipal("audit_role")); + ObjectName missing = new ObjectName("subject-test:type=Missing"); + AuditLogManager manager = AuditLogManager.instance; + manager.resetMBeanServerForwarder(); + try + { + MBeanServerForwarder forwarder = manager.getMBeanServerForwarder(); + forwarder.setMBeanServer(MBeanServerFactory.newMBeanServer("subject-test")); + InMemoryAuditLogger logger = (InMemoryAuditLogger) manager.getLogger(); + logger.inMemQueue.clear(); + for (Subject subject : new Subject[]{ authenticated, null }) + { + Subject.doAs(subject, (PrivilegedAction) () -> { + assertEquals("subject-test", forwarder.getDefaultDomain()); + Assertions.assertThatThrownBy(() -> forwarder.getAttribute(missing, "Missing")) + .isInstanceOf(InstanceNotFoundException.class); + return null; + }); + + String user = subject == null ? "null" : "CassandraPrincipal: audit_role"; + AuditLogEntry success = logger.inMemQueue.remove(); + assertEquals(AuditLogEntryType.JMX, success.getType()); + assertEquals(user, success.getUser()); + assertThat(success.getOperation(), stringContainsInOrder("JMX INVOCATION", "getDefaultDomain")); + AuditLogEntry failure = logger.inMemQueue.remove(); + assertEquals(AuditLogEntryType.JMX, failure.getType()); + assertEquals(user, failure.getUser()); + assertThat(failure.getOperation(), stringContainsInOrder("JMX FAILURE", "getAttribute")); + assertTrue(logger.inMemQueue.isEmpty()); + } + } + finally + { + manager.resetMBeanServerForwarder(); + } + } + @Test public void testJMXAuditLogs() throws Throwable { diff --git a/test/unit/org/apache/cassandra/auth/jmx/AuthorizationProxyTest.java b/test/unit/org/apache/cassandra/auth/jmx/AuthorizationProxyTest.java index f7ab44b671be..26117d8983ff 100644 --- a/test/unit/org/apache/cassandra/auth/jmx/AuthorizationProxyTest.java +++ b/test/unit/org/apache/cassandra/auth/jmx/AuthorizationProxyTest.java @@ -18,6 +18,9 @@ package org.apache.cassandra.auth.jmx; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.security.PrivilegedAction; import java.util.Collections; import java.util.HashSet; import java.util.Map; @@ -28,8 +31,12 @@ import java.util.function.Predicate; import java.util.stream.Collectors; +import javax.management.InstanceNotFoundException; +import javax.management.MBeanServer; +import javax.management.MBeanServerFactory; import javax.management.MalformedObjectNameException; import javax.management.ObjectName; +import javax.management.remote.MBeanServerForwarder; import javax.security.auth.Subject; import com.google.common.collect.ImmutableMap; @@ -45,11 +52,20 @@ import org.apache.cassandra.auth.PermissionDetails; import org.apache.cassandra.auth.RoleResource; import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.utils.JmxInvocationListener; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; public class AuthorizationProxyTest { @@ -71,6 +87,79 @@ public static void setup() throws Exception RoleResource role1 = RoleResource.role("r1"); + @Test + public void invocationUsesAuthenticatedSubject() throws Exception + { + Subject allowed = subject(role1.getRoleName()); + Subject denied = subject("denied"); + AuthorizationProxy proxy = new ProxyBuilder().isAuthzRequired(() -> true) + .isSuperuser(role -> false) + .getPermissions(role -> role.equals(role1) + ? Collections.singleton(permission(role1, JMXResource.root(), Permission.DESCRIBE)) + : Collections.emptySet()) + .build(); + JmxInvocationListener listener = mock(JmxInvocationListener.class); + proxy.listener = listener; + MBeanServerForwarder forwarder = forwarder(proxy); + Method method = MBeanServer.class.getMethod("getDefaultDomain"); + + ObjectName missing = new ObjectName("subject-test:type=Missing"); + Subject.doAs(allowed, (PrivilegedAction) () -> { + assertEquals("subject-test", forwarder.getDefaultDomain()); + assertThatThrownBy(() -> forwarder.getMBeanInfo(missing)).isInstanceOf(InstanceNotFoundException.class); + Subject.doAs(denied, (PrivilegedAction) () -> { + assertThatThrownBy(forwarder::getDefaultDomain).isInstanceOf(SecurityException.class); + return null; + }); + return null; + }); + + verify(listener).onInvocation(same(allowed), eq(method), isNull()); + verify(listener).onFailure(same(allowed), + eq(MBeanServer.class.getMethod("getMBeanInfo", ObjectName.class)), + eq(new Object[]{ missing }), + any(InstanceNotFoundException.class)); + verify(listener).onFailure(same(denied), eq(method), isNull(), any(SecurityException.class)); + verifyNoMoreInteractions(listener); + } + + @Test + public void invocationWithoutSubjectPreservesConnectorAuthorization() throws Exception + { + AuthorizationProxy proxy = new ProxyBuilder().isAuthzRequired(() -> true) + .isSuperuser(role -> { + throw new AssertionError("A connector invocation must not check a role"); + }) + .build(); + JmxInvocationListener listener = mock(JmxInvocationListener.class); + proxy.listener = listener; + MBeanServerForwarder forwarder = forwarder(proxy); + Method method = MBeanServer.class.getMethod("getDefaultDomain"); + + Subject.doAs(null, (PrivilegedAction) () -> { + assertEquals("subject-test", forwarder.getDefaultDomain()); + proxy.isAuthSetupComplete = () -> false; + assertThatThrownBy(forwarder::getDefaultDomain).isInstanceOf(SecurityException.class); + return null; + }); + + verify(listener).onInvocation(isNull(), eq(method), isNull()); + verify(listener).onFailure(isNull(), eq(method), isNull(), any(SecurityException.class)); + verifyNoMoreInteractions(listener); + } + + private static MBeanServerForwarder forwarder(AuthorizationProxy proxy) + { + MBeanServerForwarder forwarder = (MBeanServerForwarder) Proxy.newProxyInstance(MBeanServerForwarder.class.getClassLoader(), + new Class[]{ MBeanServerForwarder.class }, + proxy); + Subject.doAs(null, (PrivilegedAction) () -> { + forwarder.setMBeanServer(MBeanServerFactory.newMBeanServer("subject-test")); + return null; + }); + return forwarder; + } + @Test public void roleHasRequiredPermission() throws Throwable { diff --git a/test/unit/org/apache/cassandra/cql3/validation/entities/UFInsecureSecurityManagerTest.java b/test/unit/org/apache/cassandra/cql3/validation/entities/UFInsecureSecurityManagerTest.java new file mode 100644 index 000000000000..61033e08edc3 --- /dev/null +++ b/test/unit/org/apache/cassandra/cql3/validation/entities/UFInsecureSecurityManagerTest.java @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.cql3.validation.entities; + +import org.junit.Assume; +import org.junit.BeforeClass; + +import org.apache.cassandra.cql3.CQLTester; + +import static org.apache.cassandra.config.CassandraRelevantProperties.UDF_SECURITY_MECHANISM; + +/** Tests the settings that permit restricted System method calls with an installed security manager. */ +public class UFInsecureSecurityManagerTest extends UFInsecureSystemAccessTest +{ + @BeforeClass + public static void setUpClass() + { + Assume.assumeTrue(Runtime.version().feature() < 24); + UDF_SECURITY_MECHANISM.setString("securitymanager"); + CQLTester.setUpClass(); + } +} diff --git a/test/unit/org/apache/cassandra/cql3/validation/entities/UFInsecureSystemAccessTest.java b/test/unit/org/apache/cassandra/cql3/validation/entities/UFInsecureSystemAccessTest.java new file mode 100644 index 000000000000..7c99f32b71d1 --- /dev/null +++ b/test/unit/org/apache/cassandra/cql3/validation/entities/UFInsecureSystemAccessTest.java @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.cql3.validation.entities; + +import org.junit.BeforeClass; +import org.junit.Test; + +import org.apache.cassandra.config.Config; +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.security.ThreadAwareSecurityManager; + +import static org.apache.cassandra.config.CassandraRelevantProperties.UDF_SECURITY_MECHANISM; +import static org.junit.Assert.assertEquals; + +/** Tests both settings required for insecure system access. */ +public class UFInsecureSystemAccessTest extends CQLTester +{ + @BeforeClass + public static void setUpClass() + { + UDF_SECURITY_MECHANISM.setString("sandbox"); + CQLTester.setUpClass(); + } + + @Test + public void systemAccessRequiresBothSettings() throws Throwable + { + assertEquals(ThreadAwareSecurityManager.useSecurityManager(), System.getSecurityManager() != null); + createTable("CREATE TABLE %s (key int PRIMARY KEY, val double)"); + execute("INSERT INTO %s (key, val) VALUES (1, 0)"); + Config conf = DatabaseDescriptor.getRawConfig(); + boolean threads = conf.user_defined_functions_threads_enabled; + boolean insecure = conf.allow_extra_insecure_udfs; + String[] sources = { + "System.getProperty(\"java.version\"); return 0d;", // checkstyle: suppress nearby 'blockSystemPropertyUsage' + "System.getenv(\"PATH\"); return 0d;", // checkstyle: suppress nearby 'blockSystemPropertyUsage' + "Integer.getInteger(\"udf-test\"); return 0d;", // checkstyle: suppress nearby 'blockSystemPropertyUsage' + "Long.getLong(\"udf-test\"); return 0d;", // checkstyle: suppress nearby 'blockSystemPropertyUsage' + "Boolean.getBoolean(\"udf-test\"); return 0d;" // checkstyle: suppress nearby 'blockSystemPropertyUsage' + }; + try + { + for (boolean useThreads : new boolean[]{ false, true }) + { + for (boolean allowInsecure : new boolean[]{ false, true }) + { + conf.user_defined_functions_threads_enabled = useThreads; + conf.allow_extra_insecure_udfs = allowInsecure; + for (String source : sources) + { + if (!useThreads && allowInsecure) + { + String name = createFunction(KEYSPACE_PER_TEST, "double", function("%s", source)); + assertRows(execute("SELECT " + name + "(val) FROM %s WHERE key=1"), row(0d)); + } + else if (useThreads && ThreadAwareSecurityManager.useSecurityManager()) + { + String name = createFunction(KEYSPACE_PER_TEST, "double", function("%s", source)); + assertInvalidMessage("access denied", "SELECT " + name + "(val) FROM %s WHERE key=1"); + } + else + { + assertInvalid(function(KEYSPACE + ".restricted", source)); + } + } + assertInvalidMessage("call to java.lang.ClassLoader.getPlatformClassLoader()", + function(KEYSPACE + ".restricted", "ClassLoader.getPlatformClassLoader(); return 0d;")); + assertInvalid(function(KEYSPACE + ".restricted", "Runtime.getRuntime(); return 0d;")); + } + } + } + finally + { + conf.user_defined_functions_threads_enabled = threads; + conf.allow_extra_insecure_udfs = insecure; + } + } + + private static String function(String name, String source) + { + return "CREATE OR REPLACE FUNCTION " + name + "(val double) RETURNS NULL ON NULL INPUT " + + "RETURNS double LANGUAGE JAVA AS '" + source + "';"; + } +} diff --git a/test/unit/org/apache/cassandra/cql3/validation/entities/UFSandboxTest.java b/test/unit/org/apache/cassandra/cql3/validation/entities/UFSandboxTest.java new file mode 100644 index 000000000000..de7ef9fa3958 --- /dev/null +++ b/test/unit/org/apache/cassandra/cql3/validation/entities/UFSandboxTest.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.cql3.validation.entities; + +import org.junit.BeforeClass; +import org.junit.Test; + +import org.apache.cassandra.cql3.CQLTester; + +import static org.apache.cassandra.config.CassandraRelevantProperties.UDF_SECURITY_MECHANISM; +import static org.junit.Assert.assertNull; + +/** Runs the security and timeout tests without an installed security manager. */ +public class UFSandboxTest extends UFSecurityTest +{ + @BeforeClass + public static void setUpClass() + { + UDF_SECURITY_MECHANISM.setString("sandbox"); + CQLTester.setUpClass(); + } + + @Test + public void noSecurityManager() + { + assertNull(System.getSecurityManager()); + } + + @Test + public void fileFormatterIsRejected() throws Throwable + { + assertInvalid("CREATE FUNCTION " + KEYSPACE + ".invalid_formatter(val double) " + + "RETURNS NULL ON NULL INPUT RETURNS double LANGUAGE JAVA " + + "AS 'try { new java.util.Formatter(\"udf-sandbox-test\"); } catch (Exception e) {} return 0d;'"); + } +} diff --git a/test/unit/org/apache/cassandra/cql3/validation/entities/UFSecurityManagerTest.java b/test/unit/org/apache/cassandra/cql3/validation/entities/UFSecurityManagerTest.java new file mode 100644 index 000000000000..df8eccbacade --- /dev/null +++ b/test/unit/org/apache/cassandra/cql3/validation/entities/UFSecurityManagerTest.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.cql3.validation.entities; + +import org.junit.Assume; +import org.junit.BeforeClass; +import org.junit.Test; + +import org.apache.cassandra.cql3.CQLTester; + +import static org.apache.cassandra.config.CassandraRelevantProperties.UDF_SECURITY_MECHANISM; +import static org.junit.Assert.assertNotNull; + +/** Runs the security and timeout tests with the explicit security manager setting. */ +public class UFSecurityManagerTest extends UFSecurityTest +{ + @BeforeClass + public static void setUpClass() + { + Assume.assumeTrue(Runtime.version().feature() < 24); + UDF_SECURITY_MECHANISM.setString("securitymanager"); + CQLTester.setUpClass(); + } + + @Test + public void securityManagerInstalled() + { + assertNotNull(System.getSecurityManager()); + } +} diff --git a/test/unit/org/apache/cassandra/cql3/validation/entities/UFSecurityTest.java b/test/unit/org/apache/cassandra/cql3/validation/entities/UFSecurityTest.java index 7716e8b4e5ae..02dd0477b2ae 100644 --- a/test/unit/org/apache/cassandra/cql3/validation/entities/UFSecurityTest.java +++ b/test/unit/org/apache/cassandra/cql3/validation/entities/UFSecurityTest.java @@ -18,16 +18,27 @@ package org.apache.cassandra.cql3.validation.entities; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.net.URL; import java.security.AccessControlException; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.stream.Stream; import org.junit.Assert; +import org.junit.Assume; import org.junit.Test; import org.apache.cassandra.config.Config; import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.cql3.CQLTester; import org.apache.cassandra.exceptions.FunctionExecutionException; +import org.apache.cassandra.security.ThreadAwareSecurityManager; import org.apache.cassandra.service.ClientWarn; import org.apache.cassandra.utils.JavaDriverUtils; @@ -39,22 +50,36 @@ public void testSecurityPermissions() throws Throwable createTable("CREATE TABLE %s (key int primary key, dval double)"); execute("INSERT INTO %s (key, dval) VALUES (?, ?)", 1, 1d); - // Java UDFs + // Java user-defined functions (UDFs) - try + // UDFs must load System for timing and array operations. A SecurityManager blocks getProperty at runtime. + // The bytecode sandbox blocks it when Cassandra creates the function. + if (ThreadAwareSecurityManager.useSecurityManager()) { - String fName = createFunction(KEYSPACE_PER_TEST, "double", - "CREATE OR REPLACE FUNCTION %s(val double) " + - "RETURNS NULL ON NULL INPUT " + - "RETURNS double " + - "LANGUAGE JAVA\n" + - "AS 'System.getProperty(\"foo.bar.baz\"); return 0d;';"); // checkstyle: suppress nearby 'blockSystemPropertyUsage' - execute("SELECT " + fName + "(dval) FROM %s WHERE key=1"); - Assert.fail(); + try + { + String fName = createFunction(KEYSPACE_PER_TEST, "double", + "CREATE OR REPLACE FUNCTION %s(val double) " + + "RETURNS NULL ON NULL INPUT " + + "RETURNS double " + + "LANGUAGE JAVA\n" + + "AS 'System.getProperty(\"foo.bar.baz\"); return 0d;';"); // checkstyle: suppress nearby 'blockSystemPropertyUsage' + execute("SELECT " + fName + "(dval) FROM %s WHERE key=1"); + Assert.fail(); + } + catch (FunctionExecutionException e) + { + assertAccessControlException("System.getProperty(\"foo.bar.baz\"); return 0d;", e); // checkstyle: suppress nearby 'blockSystemPropertyUsage' + } } - catch (FunctionExecutionException e) + else { - assertAccessControlException("System.getProperty(\"foo.bar.baz\"); return 0d;", e); // checkstyle: suppress nearby 'blockSystemPropertyUsage' + assertInvalidMessage("Java UDF validation failed: [call to java.lang.System.getProperty()]", // checkstyle: suppress nearby 'blockSystemPropertyUsage' + "CREATE OR REPLACE FUNCTION " + KEYSPACE + ".invalid_get_property(val double) " + + "RETURNS NULL ON NULL INPUT " + + "RETURNS double " + + "LANGUAGE JAVA\n" + + "AS 'System.getProperty(\"foo.bar.baz\"); return 0d;';"); // checkstyle: suppress nearby 'blockSystemPropertyUsage' } String[] cfnSources = @@ -124,7 +149,12 @@ public void testSecurityPermissions() throws Throwable " org.apache.cassandra.utils.vint.VIntCoding.computeUnsignedVIntSize(0L); return 0d;" + "} catch (Exception t) {" + " throw new RuntimeException(t);" + - '}'} + '}'}, + // These classes match allowed package prefixes and expose restricted operations. + {"java.lang.ProcessHandle", "java.lang.ProcessHandle.current(); return 0d;"}, + {"java.lang.StackWalker", "java.lang.StackWalker.getInstance(); return 0d;"}, + {"java.lang.foreign.Linker","java.lang.foreign.Linker.nativeLinker(); return 0d;"}, + {"java.lang.classfile.ClassFile","java.lang.classfile.ClassFile.of(); return 0d;"} // Java Development Kit (JDK) 24 and later. }; for (String[] typeAndSource : typesAndSources) @@ -136,6 +166,104 @@ public void testSecurityPermissions() throws Throwable "LANGUAGE JAVA\n" + "AS '" + typeAndSource[1] + "';"); } + + // The class loader must resolve Module because Class references it. + // Test the verifier rules that block Module and ModuleLayer calls. + String[][] moduleApiSources = + { + {"java.lang.Class.getModule", "java.lang.Integer.class.getModule(); return 0d;"}, + {"java.lang.ModuleLayer.boot", "java.lang.ModuleLayer.boot(); return 0d;"} + }; + for (String[] moduleApiSource : moduleApiSources) + assertInvalidMessage("Java UDF validation failed: [call to " + moduleApiSource[0] + "()]", + "CREATE OR REPLACE FUNCTION " + KEYSPACE + ".invalid_module_access(val double) " + + "RETURNS NULL ON NULL INPUT " + + "RETURNS double " + + "LANGUAGE JAVA\n" + + "AS '" + moduleApiSource[1] + "';"); + } + + /** + * Tests the ClassLoader rule in the verifier. + * The verifier rejects all ClassLoader calls for both sandbox mechanisms. + */ + @Test + public void testClassLoaderAccessRejected() throws Throwable + { + // These calls compile, then the verifier rejects them. + assertInvalidMessage("Java UDF validation failed: [call to java.lang.ClassLoader.getPlatformClassLoader()]", + createClassLoaderFunction("java.lang.ClassLoader.getPlatformClassLoader(); return 0d;")); + assertInvalidMessage("Java UDF validation failed: [call to java.lang.ClassLoader.getParent()]", + createClassLoaderFunction("((java.lang.ClassLoader) null).getParent(); return 0d;")); + + // ClassLoader.resources returns a type that the UDF class loader cannot resolve. + // The compiler rejects this call. + assertInvalid(createClassLoaderFunction("((java.lang.ClassLoader) null).resources(\"x\"); return 0d;")); + } + + private static String createClassLoaderFunction(String body) + { + return "CREATE OR REPLACE FUNCTION " + KEYSPACE + ".invalid_classloader_access(val double) " + + "RETURNS NULL ON NULL INPUT " + + "RETURNS double " + + "LANGUAGE JAVA\n" + + "AS '" + body + "';"; + } + + /** + * Checks each public ClassLoader method whose return type appears in loaderYielding. + * Confirms that a UDF cannot call these methods. + */ + @Test + public void testClassLoaderLoaderYieldingMethodsDenied() throws Throwable + { + Set> loaderYielding = new HashSet<>(Arrays.asList( + ClassLoader.class, Class.class, URL.class, InputStream.class, Stream.class, Enumeration.class)); + + int checked = 0; + for (Method m : ClassLoader.class.getMethods()) + { + if (m.getDeclaringClass() != ClassLoader.class || !Modifier.isPublic(m.getModifiers())) + continue; + if (!loaderYielding.contains(m.getReturnType())) + continue; + + String target = Modifier.isStatic(m.getModifiers()) + ? "java.lang.ClassLoader" + : "((java.lang.ClassLoader) null)"; + StringBuilder args = new StringBuilder(); + for (Class p : m.getParameterTypes()) + { + if (args.length() > 0) + args.append(", "); + args.append(defaultArg(p)); + } + // Handle declared exceptions so the test body can compile. + String body = "try { " + target + '.' + m.getName() + '(' + args + "); } catch (Throwable __t) {} return 0d;"; + + // Resolvable calls reach the verifier. Calls with blocked return types fail during compilation. + assertInvalid("CREATE OR REPLACE FUNCTION " + KEYSPACE + ".invalid_classloader_enum(val double) " + + "RETURNS NULL ON NULL INPUT " + + "RETURNS double " + + "LANGUAGE JAVA\n" + + "AS '" + body + "';"); + checked++; + } + // Require enough methods to show that reflection exercised the check. + Assert.assertTrue("Expected several loader/resource-yielding ClassLoader methods, found " + checked, checked >= 3); + } + + private static String defaultArg(Class type) + { + if (type == String.class) return "\"x\""; + if (type == boolean.class) return "false"; + if (type == char.class) return "'a'"; + if (type == byte.class || type == short.class || type == int.class) return "0"; + if (type == long.class) return "0L"; + if (type == float.class) return "0f"; + if (type == double.class) return "0d"; + // Use a typed null for object and array parameters. + return "(" + type.getCanonicalName() + ") null"; } private static void assertAccessControlException(String script, FunctionExecutionException e) @@ -146,6 +274,86 @@ private static void assertAccessControlException(String script, FunctionExecutio Assert.fail("no AccessControlException for " + script + " (got " + e + ')'); } + /** + * Confirms that the bytecode sandbox rejects restricted System methods when Cassandra creates a function. + * It also confirms that timing and array methods remain available. + */ + @Test + public void testSandboxBlocksDangerousSystemMethods() throws Throwable + { + Assume.assumeFalse("legacy SecurityManager mechanism in use; covered by testSecurityPermissions", + ThreadAwareSecurityManager.useSecurityManager()); + + String[][] methodAndSource = + { + {"exit", "System.exit(1); return 0d;"}, + {"setProperty", "System.setProperty(\"foo\", \"bar\"); return 0d;"}, // checkstyle: suppress nearby 'blockSystemPropertyUsage' + {"getProperty", "System.getProperty(\"foo\"); return 0d;"}, // checkstyle: suppress nearby 'blockSystemPropertyUsage' + {"getenv", "System.getenv(\"PATH\"); return 0d;"}, // checkstyle: suppress nearby 'blockSystemPropertyUsage' + {"loadLibrary", "System.loadLibrary(\"foo\"); return 0d;"}, + {"setSecurityManager", "System.setSecurityManager(null); return 0d;"} + }; + + for (String[] ms : methodAndSource) + assertInvalidMessage("Java UDF validation failed: [call to java.lang.System." + ms[0] + "()]", + "CREATE OR REPLACE FUNCTION " + KEYSPACE + ".invalid_system_" + ms[0].toLowerCase() + "(val double) " + + "RETURNS NULL ON NULL INPUT " + + "RETURNS double " + + "LANGUAGE JAVA\n" + + "AS '" + ms[1] + "';"); + + // Test property aliases and the system logger factory. + int alias = 0; + String[][] aliasSources = + { + {"java.util.Locale.setDefault", "java.util.Locale.setDefault(java.util.Locale.FRANCE); return 0d;"}, + {"java.util.Locale.setDefault", "java.util.Locale.setDefault(java.util.Locale.Category.DISPLAY, java.util.Locale.FRANCE); return 0d;"}, + {"java.util.TimeZone.setDefault", "java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone(\"UTC\")); return 0d;"}, + {"java.util.SimpleTimeZone.setDefault", "java.util.SimpleTimeZone.setDefault(java.util.TimeZone.getTimeZone(\"UTC\")); return 0d;"}, + {"java.lang.System.getLogger", "System.getLogger(\"x\"); return 0d;"}, + {"java.lang.System$LoggerFinder.getLoggerFinder", "System.LoggerFinder.getLoggerFinder(); return 0d;"}, + {"java.lang.Integer.getInteger", "Integer.getInteger(\"x\"); return 0d;"}, // checkstyle: suppress nearby 'blockSystemPropertyUsage' + {"java.lang.Long.getLong", "Long.getLong(\"x\"); return 0d;"}, // checkstyle: suppress nearby 'blockSystemPropertyUsage' + {"java.lang.Boolean.getBoolean", "Boolean.getBoolean(\"x\"); return 0d;"} // checkstyle: suppress nearby 'blockSystemPropertyUsage' + }; + for (String[] as : aliasSources) + assertInvalidMessage("Java UDF validation failed: [call to " + as[0] + "()]", + "CREATE OR REPLACE FUNCTION " + KEYSPACE + ".invalid_alias_" + (alias++) + "(val double) " + + "RETURNS NULL ON NULL INPUT " + + "RETURNS double " + + "LANGUAGE JAVA\n" + + "AS '" + as[1] + "';"); + + // Timing methods remain available to UDFs. + String fName = createFunction(KEYSPACE_PER_TEST, "double", + "CREATE OR REPLACE FUNCTION %s(val double) " + + "RETURNS NULL ON NULL INPUT " + + "RETURNS double " + + "LANGUAGE JAVA\n" + + "AS 'return (double) (System.nanoTime() - System.currentTimeMillis());';"); + createTable("CREATE TABLE %s (key int PRIMARY KEY, val double)"); + execute("INSERT INTO %s (key, val) VALUES (1, 0)"); + Assert.assertEquals(1, execute("SELECT " + fName + "(val) FROM %s WHERE key=1").size()); + String arrayFunction = createFunction(KEYSPACE_PER_TEST, "double", + "CREATE FUNCTION %s(val double) RETURNS NULL ON NULL INPUT " + + "RETURNS double LANGUAGE JAVA AS 'double[] a = { val }; " + + "double[] b = new double[1]; System.arraycopy(a, 0, b, 0, 1); return b[0];'"); + assertRows(execute("SELECT " + arrayFunction + "(val) FROM %s WHERE key=1"), row(0d)); + } + + /** Confirms that a UDF cannot emit a second class file that the verifier would not inspect. */ + @Test + public void testRejectsAdditionalClasses() throws Throwable + { + // An anonymous Object subclass emits a second class file without using a blocked method. + assertInvalidMessage("the function must not declare additional classes", + "CREATE OR REPLACE FUNCTION " + KEYSPACE + ".invalid_inner_class(val double) " + + "RETURNS NULL ON NULL INPUT " + + "RETURNS double " + + "LANGUAGE JAVA\n" + + "AS 'return (double) new Object(){}.hashCode();';"); + } + @Test public void testAmokUDF() throws Throwable { diff --git a/test/unit/org/apache/cassandra/security/JMXSubjectsTest.java b/test/unit/org/apache/cassandra/security/JMXSubjectsTest.java new file mode 100644 index 000000000000..8dce187f9657 --- /dev/null +++ b/test/unit/org/apache/cassandra/security/JMXSubjectsTest.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.security; + +import java.security.PrivilegedAction; +import java.util.concurrent.Callable; + +import javax.security.auth.Subject; + +import org.junit.Test; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +public class JMXSubjectsTest +{ + @Test + public void authenticatedSubject() throws Exception + { + assertNull(JMXSubjects.current()); + Subject subject = new Subject(); + Subject other = new Subject(); + Subject.doAs(subject, (PrivilegedAction) () -> { + assertSame(subject, JMXSubjects.current()); + Subject.doAs(other, (PrivilegedAction) () -> { + assertSame(other, JMXSubjects.current()); + return null; + }); + assertSame(subject, JMXSubjects.current()); + return null; + }); + assertNull(JMXSubjects.current()); + } + + @Test + public void reflectiveLookup() throws Exception + { + if (Runtime.version().feature() < 18) + return; + Subject subject = new Subject(); + // Exercise the newer lookup on supported runtimes that already provide Subject.callAs. + Subject.class.getMethod("callAs", Subject.class, Callable.class).invoke(null, subject, (Callable) () -> { + assertSame(subject, JMXSubjects.currentSubject()); + return null; + }); + assertNull(JMXSubjects.currentSubject()); + } +} diff --git a/test/unit/org/apache/cassandra/security/ThreadAwareSecurityManagerTest.java b/test/unit/org/apache/cassandra/security/ThreadAwareSecurityManagerTest.java new file mode 100644 index 000000000000..e09ddda26cfe --- /dev/null +++ b/test/unit/org/apache/cassandra/security/ThreadAwareSecurityManagerTest.java @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.security; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.security.Policy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import com.google.common.base.StandardSystemProperty; + +import org.junit.Test; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.exceptions.ConfigurationException; +import org.apache.cassandra.utils.logging.SlowQueriesAppender; +import org.apache.cassandra.utils.logging.VirtualTableAppender; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.Appender; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class ThreadAwareSecurityManagerTest +{ + @Test + public void selection() + { + for (int version : new int[]{ 11, 17, 21, 23, 24, 25 }) + { + assertEquals(version < 24, ThreadAwareSecurityManager.useSecurityManager("auto", version)); + assertTrue(ThreadAwareSecurityManager.useSecurityManager("securitymanager", version)); + assertFalse(ThreadAwareSecurityManager.useSecurityManager("sandbox", version)); + } + assertFalse(ThreadAwareSecurityManager.useSecurityManager(" SANDBOX ", 11)); + for (String invalid : new String[]{ "", "false", "disabled", "security-manager" }) + { + try + { + ThreadAwareSecurityManager.useSecurityManager(invalid, 11); + fail("Accepted " + invalid); + } + catch (ConfigurationException e) + { + assertTrue(e.getMessage().contains("cassandra.udf.security_mechanism")); + } + } + } + + @Test + public void startup() throws Exception + { + startup("sandbox", false, false); + startup("auto", Runtime.version().feature() < 24, false); + startup("securitymanager", true, Runtime.version().feature() >= 24); + startup("invalid", false, true); + if (Runtime.version().feature() >= 12) + startup("securitymanager", true, true, "-Djava.security.manager=disallow"); + } + + private static void startup(String mechanism, boolean installed, boolean failure, String... options) throws Exception + { + startup(mechanism, Startup.class, Arrays.asList(Boolean.toString(installed), Boolean.toString(failure)), options); + } + + @Test + public void loggingStartup() throws Exception + { + for (String mechanism : new String[]{ "sandbox", "auto" }) + { + startup(mechanism, LoggingStartup.class, Arrays.asList("virtual")); + startup(mechanism, LoggingStartup.class, Arrays.asList("slow")); + } + } + + private static void startup(String mechanism, Class mainClass, List args, String... options) throws Exception + { + List command = new ArrayList<>(); + command.add(StandardSystemProperty.JAVA_HOME.value() + File.separator + "bin" + File.separator + "java"); + if (Runtime.version().feature() >= 17 && Runtime.version().feature() < 24) + command.add("-Djava.security.manager=allow"); + command.addAll(Arrays.asList(options)); + command.add("-Dcassandra.udf.security_mechanism=" + mechanism); + command.add("-cp"); + command.add(StandardSystemProperty.JAVA_CLASS_PATH.value()); + command.add(mainClass.getName()); + command.addAll(args); + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + try + { + assertTrue("Startup did not finish", process.waitFor(30, TimeUnit.SECONDS)); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(output, 0, process.exitValue()); + } + finally + { + process.destroyForcibly(); + } + } + + public static class LoggingStartup + { + @SuppressWarnings("unchecked") + public static void main(String[] args) + { + DatabaseDescriptor.clientInitialization(); + Supplier> factory = args[0].equals("virtual") ? VirtualTableAppender::new : SlowQueriesAppender::new; + Appender first = factory.get(); + Appender second = factory.get(); + first.setName("first"); + second.setName("second"); + Logger logger = (Logger) LoggerFactory.getLogger(LoggingStartup.class); + // Logback supplies LoggingEvent instances to these appenders. + logger.addAppender((Appender) first); + logger.addAppender((Appender) second); + try + { + ThreadAwareSecurityManager.install(); + throw new AssertionError("Expected duplicate appender failure"); + } + catch (IllegalStateException e) + { + if (!e.getMessage().contains("multiple appenders of class " + first.getClass().getName())) + throw e; + } + } + } + + public static class Startup + { + public static void main(String[] args) + { + DatabaseDescriptor.clientInitialization(); + Policy policy = Runtime.version().feature() < 24 ? Policy.getPolicy() : null; + ThreadAwareSecurityManager.isSecuredThread(); + boolean failure = Boolean.parseBoolean(args[1]); + try + { + ThreadAwareSecurityManager.install(); + if (failure) + throw new AssertionError("Expected configuration failure"); + if ((System.getSecurityManager() != null) != Boolean.parseBoolean(args[0])) + throw new AssertionError("Unexpected installed security manager"); + if (!Boolean.parseBoolean(args[0]) && Runtime.version().feature() < 24 && policy != Policy.getPolicy()) + throw new AssertionError("Sandbox changed the security policy"); + } + catch (ConfigurationException e) + { + if (!failure) + throw e; + } + } + } +}