-
Notifications
You must be signed in to change notification settings - Fork 70
fix(lint): restrict data-plan detector to known event builder types #817
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package com.mparticle.lints.dtos | ||
|
|
||
| /** | ||
| * The Data Plan detector needs to compute the message body of an event builder chain found in | ||
| * analyzed source, which requires resolving constructor/static calls via reflection. Reflection | ||
| * must never run against a class named by the analyzed source itself unless it's one of the | ||
| * mParticle DTO/builder types (or a small set of collection helpers commonly used inline to build | ||
| * attribute maps) - anything else is attacker-controlled input, not code we intend to execute. | ||
| */ | ||
| internal object AllowedTypes { | ||
| private val ALLOWED_CLASS_NAMES = | ||
| setOf( | ||
| "com.mparticle.MPEvent", | ||
| "com.mparticle.MPEvent\$Builder", | ||
| "com.mparticle.commerce.CommerceEvent", | ||
| "com.mparticle.commerce.CommerceEvent\$Builder", | ||
| "com.mparticle.commerce.Product", | ||
| "com.mparticle.commerce.Product\$Builder", | ||
| "com.mparticle.commerce.Promotion", | ||
| "com.mparticle.commerce.Impression", | ||
| "com.mparticle.commerce.TransactionAttributes", | ||
| "com.mparticle.MParticle\$EventType", | ||
| "java.util.HashMap", | ||
| "java.util.LinkedHashMap", | ||
| "java.util.ArrayList", | ||
| "kotlin.collections.MapsKt", | ||
| "kotlin.collections.CollectionsKt", | ||
| ) | ||
|
|
||
| fun isAllowed(qualifiedClassName: String?): Boolean = qualifiedClassName != null && ALLOWED_CLASS_NAMES.contains(qualifiedClassName) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,10 +10,10 @@ data class MethodCall(override val parent: Expression, val methodName: String?, | |
| arguments.forEach { it.parent = this } | ||
| } | ||
|
|
||
| override fun resolve(): Any? { | ||
| override fun resolve(): Any? = ResolutionGuard.guarded { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MethodCall.resolve() is never gated, and that’s the widest call site. resolves its parent and then invokes any method matching name + arity on whatever object comes back, via instance::class.java.methods — which includes inherited Object methods and statics. So the allowlist can be walked around without ever defeating it: That’s valid Java (static-through-instance is legal; Class.newInstance() is a real instance method), it passes CallScanner.ofInterest, and resolveChainedCalls folds it into Constructor → MethodCall(getClass) → MethodCall(forName) → MethodCall(newInstance). getClass() returns a java.lang.Class, and from that point the receiver type is Class, so forName / getResource / newInstance all match on name and arity. Result: arbitrary static-initializer execution plus no-arg instantiation of anything on the lint classpath, and each subsequent chained call then invokes name/arity-matched methods on that new object with literal arguments. Suggested fix: reject in MethodCall.resolve() unless instance::class.java.name is allowlisted, before the method search. That’s one guard at the shared chokepoint rather than per-call-site. AllowedTypes needs the value types that allowlisted methods legitimately return (MPEvent, CommerceEvent, String, boxed primitives, Double for Product.getTotalAmount(), the allowed collections). |
||
| val instance = parent.resolve() | ||
| if (instance == null) { | ||
| return null | ||
| return@guarded null | ||
| } | ||
| var matchingMethods = | ||
| instance::class.java.methods | ||
|
|
@@ -33,12 +33,12 @@ data class MethodCall(override val parent: Expression, val methodName: String?, | |
| val arguments = arguments.resolve() | ||
| val value = method.invoke(instance, *arguments.toTypedArray()) | ||
| if (returnValue) { | ||
| return value | ||
| return@guarded value | ||
| } else { | ||
| return instance | ||
| return@guarded instance | ||
| } | ||
| } | ||
| return null | ||
| return@guarded null | ||
| } | ||
|
|
||
| override fun equals(other: Any?): Boolean = node.equals((other as? MethodCall)?.node) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.mparticle.lints.dtos | ||
|
|
||
| /** | ||
| * Bounds the depth of expression resolution. Some qualified call chains cause a resolved | ||
| * expression's parent to keep resolving into itself; without a bound that recurses until the | ||
| * stack overflows, which callers' try/catch(Exception) blocks don't catch. Throwing a regular | ||
| * exception once a generous depth is exceeded keeps that failure catchable. | ||
| */ | ||
| internal object ResolutionGuard { | ||
| private const val MAX_DEPTH = 50 | ||
| private val depth = ThreadLocal.withInitial { 0 } | ||
|
|
||
| fun <T> guarded(block: () -> T): T { | ||
| val current = depth.get() | ||
| if (current >= MAX_DEPTH) { | ||
| throw IllegalStateException("Expression resolution exceeded max depth of $MAX_DEPTH") | ||
| } | ||
| depth.set(current + 1) | ||
| try { | ||
| return block() | ||
| } finally { | ||
| depth.set(current) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,8 +9,11 @@ class StaticFactory(val methodName: String?, override val node: UCallExpression) | |
| override val parent = RootParent(node) | ||
| override var arguments: List<Value> = listOf() | ||
|
|
||
| override fun resolve(): Any? { | ||
| override fun resolve(): Any? = ResolutionGuard.guarded { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. StaticFactory’s Kotlin allowlist entries look functionally wrong StaticFactory derives the name from (node.resolve()?.parent as? ClsClassImpl)?.stub?.qualifiedName, which for a Kotlin multifile facade gives the part class — kotlin.collections.MapsKt__MapsKt, not MapsKt. (Extensions.kt:176 has to split("__")[0] for exactly this reason.) If that holds, mapOf(...) / listOf(...) attribute maps now resolve to null and feed straight into item 3 — the existing testCollection won’t catch it because it never builds an event. There’s no Kotlin test in the PR either way, so worth confirming. Separately, MapsKt / CollectionsKt are allowed at class granularity with declaredMethods + isAccessible = true; allowlisting method names rather than classes would fix both the breadth and the facade-name problem. |
||
| val qualifiedClassName = (node.resolve()?.parent as? ClsClassImpl)?.stub?.qualifiedName | ||
| if (!AllowedTypes.isAllowed(qualifiedClassName)) { | ||
| return@guarded null | ||
| } | ||
| val methods = HashSet<Method>() | ||
| val clazz = Class.forName(qualifiedClassName) | ||
| methods.addAll(clazz.declaredMethods) | ||
|
|
@@ -31,9 +34,9 @@ class StaticFactory(val methodName: String?, override val node: UCallExpression) | |
| } | ||
| val arguments = arguments.resolve() | ||
| method.isAccessible = true | ||
| return method.invoke(null, *arguments.toTypedArray()) | ||
| return@guarded method.invoke(null, *arguments.toTypedArray()) | ||
| } | ||
| return null | ||
| return@guarded null | ||
| } | ||
|
|
||
| override fun forEachExpression(predicate: (Expression) -> Unit) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Still returns clazz on instantiation failure.
A second route to a Class instance that doesn’t even need getClass(): new MPEvent.Builder(0) → getConstructor(Integer) misses → arity-1 fallback picks Builder(String) → newInstance(Integer) throws → caught → the Class is returned. Same escalation as above. Return null instead.