From 8fa3fb05612f6660e1464be31ff9396143917543 Mon Sep 17 00:00:00 2001 From: Asger Feldthaus Date: Mon, 25 Jan 2021 13:00:27 +0000 Subject: [PATCH] JS: Redux model --- javascript/ql/src/javascript.qll | 1 + .../semmle/javascript/frameworks/Redux.qll | 1285 +++++++++++++++++ .../frameworks/Redux/exportedReducer.js | 13 + .../frameworks/Redux/react-redux.jsx | 79 + .../frameworks/Redux/test.expected | 154 ++ .../library-tests/frameworks/Redux/test.ql | 61 + .../library-tests/frameworks/Redux/trivial.js | 137 ++ 7 files changed, 1730 insertions(+) create mode 100644 javascript/ql/src/semmle/javascript/frameworks/Redux.qll create mode 100644 javascript/ql/test/library-tests/frameworks/Redux/exportedReducer.js create mode 100644 javascript/ql/test/library-tests/frameworks/Redux/react-redux.jsx create mode 100644 javascript/ql/test/library-tests/frameworks/Redux/test.expected create mode 100644 javascript/ql/test/library-tests/frameworks/Redux/test.ql create mode 100644 javascript/ql/test/library-tests/frameworks/Redux/trivial.js diff --git a/javascript/ql/src/javascript.qll b/javascript/ql/src/javascript.qll index 66dccd8cddd..b565a7cd21f 100644 --- a/javascript/ql/src/javascript.qll +++ b/javascript/ql/src/javascript.qll @@ -105,6 +105,7 @@ import semmle.javascript.frameworks.PropertyProjection import semmle.javascript.frameworks.Puppeteer import semmle.javascript.frameworks.React import semmle.javascript.frameworks.ReactNative +import semmle.javascript.frameworks.Redux import semmle.javascript.frameworks.Request import semmle.javascript.frameworks.RxJS import semmle.javascript.frameworks.ServerLess diff --git a/javascript/ql/src/semmle/javascript/frameworks/Redux.qll b/javascript/ql/src/semmle/javascript/frameworks/Redux.qll new file mode 100644 index 00000000000..7f22c3638e2 --- /dev/null +++ b/javascript/ql/src/semmle/javascript/frameworks/Redux.qll @@ -0,0 +1,1285 @@ +/** + * Provides classes and predicates for reasoning about data flow through the redux package. + */ + +import javascript +private import semmle.javascript.dataflow.internal.PreCallGraphStep +private import semmle.javascript.Unit + +/** + * Provides classes and predicates for reasoning about data flow through the redux package. + */ +module Redux { + /** + * To avoid mixing up the state between independent Redux apps that live in a monorepo, + * we do a heuristic program slicing based on `package.json` files. For most projects this has no effect. + */ + private module ProgramSlicing { + /** Gets the innermost `package.json` file in a directory containing the given file. */ + private PackageJSON getPackageJson(Container f) { + f = result.getFile().getParentContainer() + or + not exists(f.getFile("package.json")) and + result = getPackageJson(f.getParentContainer()) + } + + private predicate packageDependsOn(PackageJSON importer, PackageJSON dependency) { + importer.getADependenciesObject("").getADependency(dependency.getPackageName(), _) + } + + /** A package that can be considered an entry point for a Redux app. */ + private PackageJSON entryPointPackage() { + result = getPackageJson(any(StoreCreation c).getFile()) + or + // Any package that imports a store-creating package is considered a potential entry point. + packageDependsOn(result, entryPointPackage()) + } + + pragma[nomagic] + private predicate arePackagesInSameReduxApp(PackageJSON a, PackageJSON b) { + exists(PackageJSON entry | + entry = entryPointPackage() and + packageDependsOn*(entry, a) and + packageDependsOn*(entry, b) + ) + } + + /** Holds if the two files are considered to be part of the same Redux app. */ + pragma[inline] + predicate areFilesInSameReduxApp(File a, File b) { + not exists(PackageJSON pkg) + or + arePackagesInSameReduxApp(getPackageJson(a), getPackageJson(b)) + } + } + + /** + * Creation of a redux store, usually via a call to `createStore`. + */ + class StoreCreation extends DataFlow::SourceNode { + StoreCreation::Range range; + + StoreCreation() { this = range } + + /** Gets a reference to the store. */ + DataFlow::SourceNode ref() { + // We happen to know that all store-creation sources have API nodes, so just reuse the API node type tracking + exists(API::Node apiNode | + apiNode.getAnImmediateUse() = this and + result = apiNode.getAUse() + ) + } + + /** Gets the data flow node holding the root reducer for this store. */ + DataFlow::Node getReducerArg() { result = range.getReducerArg() } + + /** Gets a data flow node referring to the root reducer. */ + DataFlow::SourceNode getAReducerSource() { result = getReducerArg().(ReducerArg).getASource() } + } + + /** Companion module to the `StoreCreation` class. */ + module StoreCreation { + /** + * Creation of a redux store. Additional `StoreCreation` instances can be generated by subclassing this class. + */ + abstract class Range extends DataFlow::SourceNode { + /** Gets the data flow node holding the root reducer for this store. */ + abstract DataFlow::Node getReducerArg(); + } + + private class CreateStore extends DataFlow::CallNode, Range { + CreateStore() { + this = API::moduleImport(["redux", "@reduxjs/toolkit"]).getMember("createStore").getACall() + } + + override DataFlow::Node getReducerArg() { result = getArgument(0) } + } + + private class ToolkitStore extends API::CallNode, Range { + ToolkitStore() { + this = API::moduleImport("@reduxjs/toolkit").getMember("configureStore").getACall() + } + + override DataFlow::Node getReducerArg() { + result = getParameter(0).getMember("reducer").getARhs() + } + } + } + + /** An API node that is a source of the Redux root state. */ + abstract private class RootStateSource extends API::Node { } + + /** Gets an API node referring to the Redux root state. */ + private API::Node rootState() { + result instanceof RootStateSource + or + stateStep(rootState().getAUse(), result.getAnImmediateUse()) + } + + /** + * Gets an API node referring to the given (non-empty) access path within the Redux state. + */ + private API::Node rootStateAccessPath(string accessPath) { + result = rootState().getMember(accessPath) + or + exists(string base, string prop | + result = rootStateAccessPath(base).getMember(prop) and + accessPath = joinAccessPaths(base, prop) + ) + or + stateStep(rootStateAccessPath(accessPath).getAUse(), result.getAnImmediateUse()) + } + + /** + * Combines two state access paths, while disallowing unbounded growth of access paths. + */ + bindingset[base, prop] + private string joinAccessPaths(string base, string prop) { + result = base + "." + prop and + // Allow at most two occurrences of a given property name in the path + // (one in the base, plus the one we're appending now). + count(base.indexOf("." + prop + ".")) <= 1 + } + + /** + * Creation of a reducer function that delegates to one or more other reducer functions. + * + * Delegating reducers can delegate specific parts of the state object (`getStateHandlerArg`), + * actions of a specific type (`getActionHandlerArg`), or everything (`getAPlainHandlerArg`). + */ + abstract class DelegatingReducer extends DataFlow::SourceNode { + /** + * Gets a data flow node holding a reducer to which handling of `state.prop` is delegated. + * + * For example, gets the `fn` in `combineReducers({foo: fn})` with `prop` bound to `foo`. + * + * The delegating reducer should behave as a function of this form: + * ```js + * function outer(state, action) { + * return { + * prop: inner(state.prop, action), + * ... + * } + * } + * ``` + */ + DataFlow::Node getStateHandlerArg(string prop) { none() } + + /** + * Gets a data flow node holding a reducer to which actions of the given type are delegated. + * + * For example, gets the `fn` in `handleAction(a, fn)` with `actionType` bound to `a`. + * + * The `actionType` node may refer an action creator or a string value corresponding to `action.type`. + */ + DataFlow::Node getActionHandlerArg(DataFlow::Node actionType) { none() } + + /** + * Gets a data flow node holding a reducer to which every request is forwarded (for the + * purpose of this model). + * + * For example, gets the `fn` in `persistReducer(config, fn)`. + */ + DataFlow::Node getAPlainHandlerArg() { none() } + + /** Gets the use site of this reducer. */ + final ReducerArg getUseSite() { result.getASource() = this } + } + + private module DelegatingReducer { + private API::Node combineReducers() { + result = + API::moduleImport(["redux", "redux-immutable", "@reduxjs/toolkit"]) + .getMember("combineReducers") + } + + /** + * A call to `combineReducers`, which delegates properties of `state` to individual sub-reducers. + */ + private class CombineReducers extends API::CallNode, DelegatingReducer { + CombineReducers() { this = combineReducers().getACall() } + + override DataFlow::Node getStateHandlerArg(string prop) { + result = getParameter(0).getMember(prop).getARhs() + } + } + + /** + * An object literal flowing into a nested property in a `combineReducers` object, such as the `{ bar }` object in: + * ```js + * combineReducers({ foo: { bar } }) + * ``` + * + * Although the object itself is clearly not a function, we use the object to model the corresponding reducer function created by `combineReducers`. + */ + private class NestedCombineReducers extends DelegatingReducer, DataFlow::ObjectLiteralNode { + NestedCombineReducers() { + this = combineReducers().getParameter(0).getAMember+().getAValueReachingRhs() + } + + override DataFlow::Node getStateHandlerArg(string prop) { + result = getAPropertyWrite(prop).getRhs() + } + } + + /** + * A call to `handleActions`, creating a reducer function that dispatched based on the action type: + * + * ```js + * let reducer = handleActions({ + * actionType1: (state, action) => { ... }, + * actionType2: (state, action) => { ... }, + * }) + * ``` + */ + private class HandleActions extends API::CallNode, DelegatingReducer { + HandleActions() { + this = + API::moduleImport(["redux-actions", "redux-ts-utils"]) + .getMember("handleActions") + .getACall() + } + + override DataFlow::Node getActionHandlerArg(DataFlow::Node actionType) { + exists(DataFlow::PropWrite write | + result = getParameter(0).getAMember().getARhs() and + write.getRhs() = result and + actionType = write.getPropertyNameExpr().flow() + ) + } + } + + /** + * A call to `handleAction`, creating a reducer function that only handles a given action type: + * + * ```js + * let reducer = handleAction('actionType', (state, action) => { ... }); + * ``` + */ + private class HandleAction extends API::CallNode, DelegatingReducer { + HandleAction() { + this = + API::moduleImport(["redux-actions", "redux-ts-utils"]) + .getMember("handleAction") + .getACall() + } + + override DataFlow::Node getActionHandlerArg(DataFlow::Node actionType) { + actionType = getArgument(0) and + result = getArgument(1) + } + } + + /** + * A call to `persistReducer`, which we model as a plain wrapper around another reducer. + */ + private class PersistReducer extends DataFlow::CallNode, DelegatingReducer { + PersistReducer() { + this = API::moduleImport("redux-persist").getMember("persistReducer").getACall() + } + + override DataFlow::Node getAPlainHandlerArg() { result = getArgument(1) } + } + + /** + * A call to `immer` or `immer.produce`, which we model as a plain wrapper around another reducer. + */ + private class ImmerProduce extends DataFlow::CallNode, DelegatingReducer { + ImmerProduce() { + this = API::moduleImport("immer").getACall() + or + this = API::moduleImport("immer").getMember("produce").getACall() + } + + override DataFlow::Node getAPlainHandlerArg() { result = getArgument(0) } + } + + /** + * A call to `reduce-reducers`, modelled as a reducer that dispatches to an arbitrary subreducer. + * + * In reality, this function chains together all of the reducers, but in practice it is only used + * when the reducers handle a disjoint set of action types, which makes it behave as if it + * dispatched to just one of them. + * + * For example: + * ```js + * let reducer = reduceReducers([ + * handleAction('action1', (state, action) => { ... }), + * handleAction('action2', (state, action) => { ... }), + * ]); + * ``` + */ + private class ReduceReducers extends DataFlow::CallNode, DelegatingReducer { + ReduceReducers() { + this = API::moduleImport("reduce-reducers").getACall() or + this = + API::moduleImport(["redux-actions", "redux-ts-utils"]) + .getMember("reduceReducers") + .getACall() + } + + override DataFlow::Node getAPlainHandlerArg() { + result = getAnArgument() + or + result = getArgument(0).getALocalSource().(DataFlow::ArrayCreationNode).getAnElement() + } + } + + /** + * A call to `createReducer`, for example: + * + * ```js + * let reducer = createReducer(initialState, (builder) => { + * builder + * .addCase(actionType1, (state, action) => { ... }) + * .addCase(actionType2, (state, action) => { ... }); + * }); + * ``` + */ + private class CreateReducer extends API::CallNode, DelegatingReducer { + CreateReducer() { + this = API::moduleImport("@reduxjs/toolkit").getMember("createReducer").getACall() + } + + private API::Node getABuilderRef() { + result = getParameter(1).getParameter(0) + or + result = getABuilderRef().getAMember().getReturn() + } + + override DataFlow::Node getActionHandlerArg(DataFlow::Node actionType) { + exists(API::CallNode addCase | + addCase = getABuilderRef().getMember("addCase").getACall() and + actionType = addCase.getArgument(0) and + result = addCase.getArgument(1) + ) + } + } + + /** + * A reducer created by a call to `createSlice`. Note that `createSlice` creates both + * reducers and actions; this class models the reducers only. + * + * For example: + * ```js + * let slice = createSlice({ + * name: 'mySlice', + * reducers: { + * actionType1: (state, action) => { ... }, + * actionType2: (state, action) => { ... }, + * }, + * extraReducers: (builder) => { + * builder.addCase('actionType3', (state, action) => { ... }) + * } + * }); + * export default slice.reducer; + * ``` + */ + private class CreateSliceReducer extends DelegatingReducer { + API::CallNode call; + + CreateSliceReducer() { + call = API::moduleImport("@reduxjs/toolkit").getMember("createSlice").getACall() and + this = call.getReturn().getMember("reducer").getAnImmediateUse() + } + + private API::Node getABuilderRef() { + result = call.getParameter(0).getMember("extraReducers").getParameter(0) + or + result = getABuilderRef().getAMember().getReturn() + } + + override DataFlow::Node getActionHandlerArg(DataFlow::Node actionType) { + exists(string name | + result = call.getParameter(0).getMember("reducers").getMember(name).getARhs() and + actionType = call.getReturn().getMember("actions").getMember(name).getAnImmediateUse() + ) + or + // Properties of 'extraReducers': + // { extraReducers: { [action]: reducer }} + exists(DataFlow::PropWrite write | + result = call.getParameter(0).getMember("extraReducers").getAMember().getARhs() and + write.getRhs() = result and + actionType = write.getPropertyNameExpr().flow() + ) + or + // Builder callback to 'extraReducers': + // extraReducers: builder => builder.addCase(action, reducer) + exists(API::CallNode addCase | + addCase = getABuilderRef().getMember("addCase").getACall() and + actionType = addCase.getArgument(0) and + result = addCase.getArgument(1) + ) + } + } + } + + /** + * A function for creating and dispatching action objects of shape `{type, payload}`. + * + * In the simplest case, an action creator is a function, which, for some string `T` behaves as the function `x => {type: T, payload: x}`. + * + * An action creator may have a middleware function `f`, which makes it behave as the function `x => {type: T, payload: f(x)}` (that is, + * the function `f` converts the argument into the actual payload). + * + * Some action creators dispatch the action to a store, while for others, the value is returned and it is simply assumed to be dispatched + * at some point. We model all action creators as if they dispatch the action they create. + */ + class ActionCreator extends DataFlow::SourceNode { + ActionCreator::Range range; + + ActionCreator() { this = range } + + /** Gets the `type` property of actions created by this action creator, if it is known. */ + string getTypeTag() { result = range.getTypeTag() } + + /** + * Gets the middleware function that transforms arguments passed to this function into the + * action payload. + * + * Not every action creator has a middleware function; in such cases the first argument is + * treated as the action payload. + * + * If `async` is true, the middlware function returns a promise whose value eventually becomes + * the action payload. Otherwise, the return value is the payload itself. + */ + DataFlow::FunctionNode getMiddlewareFunction(boolean async) { + result = range.getMiddlewareFunction(async) + } + + /** Gets a data flow node referring to this action creator. */ + private DataFlow::SourceNode ref(DataFlow::TypeTracker t) { + t.start() and + result = this + or + // x -> bindActionCreators({ x, ... }) + exists(BindActionCreatorsCall bind, string prop | + ref(t.continue()).flowsTo(bind.getParameter(0).getMember(prop).getARhs()) and + result = bind.getReturn().getMember(prop).getAnImmediateUse() + ) + or + // x -> combineActions(x, ...) + exists(API::CallNode combiner | + combiner = + API::moduleImport(["redux-actions", "redux-ts-utils"]) + .getMember("combineActions") + .getACall() and + ref(t.continue()).flowsTo(combiner.getAnArgument()) and + result = combiner + ) + or + // x -> x.fulfilled, for async action creators + result = ref(t.continue()).getAPropertyRead("fulfilled") + or + // follow flow through mapDispatchToProps + ReactRedux::dispatchToPropsStep(ref(t.continue()).getALocalUse(), result) + or + exists(DataFlow::TypeTracker t2 | result = ref(t2).track(t2, t)) + } + + /** Gets a data flow node referring to this action creator. */ + DataFlow::SourceNode ref() { result = ref(DataFlow::TypeTracker::end()) } + + /** + * Holds if `successBlock` is executed when a check has determined that `action` originated from this action creator. + */ + private ReachableBasicBlock getASuccessfulTypeCheckBlock(DataFlow::SourceNode action) { + action = getAnUntypedActionInReducer() and + result = getASuccessfulTypeCheckBlock(action, getTypeTag()) + or + // and ProgramSlicing::areFilesInSameReduxApp(result.getFile(), this.getFile()) -- TODO delete? + // some action creators implement a .match method for this purpose + exists(ConditionGuardNode guard, DataFlow::CallNode call | + call = ref().getAMethodCall("match") and + guard.getTest() = call.asExpr() and + action.flowsTo(call.getArgument(0)) and + guard.getOutcome() = true and + result = guard.getBasicBlock() + ) + } + + /** + * Gets a reducer that handles the type of action created by this action creator, for example: + * ```js + * handleAction(TYPE, (state, action) => { ... action.payload ... }) + * ``` + * + * Does not include reducers that perform their own action type checking. + */ + DataFlow::FunctionNode getAReducerFunction() { + exists(ReducerArg reducer | + reducer.isTypeTagHandler(getTypeTag()) + or + reducer.isActionTypeHandler(ref().getALocalUse()) + | + result = reducer.getASource() + ) + } + + /** Gets a data flow node referring a payload of this action (usually in the reducer function). */ + DataFlow::SourceNode getAPayloadReference() { + // `if (action.type === TYPE) { ... action.payload ... }` + exists(DataFlow::SourceNode actionSrc | + actionSrc = getAnUntypedActionInReducer() and + result = actionSrc.getAPropertyRead("payload") and + getASuccessfulTypeCheckBlock(actionSrc).dominates(result.getBasicBlock()) + ) + or + result = getAReducerFunction().getParameter(1).getAPropertyRead("payload") + } + + /** Gets a data flow node referring to the first argument of the action creator invocation. */ + DataFlow::SourceNode getAMetaArgReference() { + exists(ReducerArg reducer | + reducer.isActionTypeHandler(ref().getAPropertyRead(["fulfilled", "rejected", "pending"])) and + result = + reducer + .getASource() + .(DataFlow::FunctionNode) + .getParameter(1) + .getAPropertyRead("meta") + .getAPropertyRead("arg") + ) + } + } + + /** Companion module to the `ActionCreator` class. */ + module ActionCreator { + /** A function for creating and dispatching action objects of shape `{type, payload}`. */ + abstract class Range extends DataFlow::SourceNode { + /** Gets the `type` property of actions created by this action creator */ + abstract string getTypeTag(); + + /** Gets the function transforming arguments into the action payload. */ + DataFlow::FunctionNode getMiddlewareFunction(boolean async) { none() } + } + + /** + * An action creator made using `createAction`: + * ```js + * let action1 = createAction('action1'); + * let action2 = createAction('action2', (x,y) => { x, y }); + * ``` + */ + private class SingleAction extends Range, API::CallNode { + SingleAction() { + this = + API::moduleImport(["@reduxjs/toolkit", "redux-actions", "redux-ts-utils"]) + .getMember("createAction") + .getACall() + } + + override string getTypeTag() { getArgument(0).mayHaveStringValue(result) } + + override DataFlow::FunctionNode getMiddlewareFunction(boolean async) { + result = getCallback(1) and async = false + } + } + + /** + * One of the action creators made by a call to `createActions`: + * ```js + * let { actionOne, actionTwo } = createActions({ + * ACTION_ONE: (x, y) => { x, y }, + * ACTION_TWO: (x, y) => { x, y }, + * }) + * ``` + */ + class MultiAction extends Range { + API::CallNode createActions; + string name; + + MultiAction() { + createActions = API::moduleImport("redux-actions").getMember("createActions").getACall() and + this = createActions.getReturn().getMember(name).getAnImmediateUse() + } + + override DataFlow::FunctionNode getMiddlewareFunction(boolean async) { + result.flowsTo(createActions.getParameter(0).getMember(getTypeTag()).getARhs()) and + async = false + } + + override string getTypeTag() { + result = name.regexpReplaceAll("([a-z])([A-Z])", "$1_$2").toUpperCase() + } + } + + /** + * An action creator made by a call to `createSlice`. Note that `createSlice` creates both + * reducers and actions; this class models the action creators. + * + * ```js + * let slice = createSlice({ + * name: 'mySlice', + * reducers: { + * actionType1: (state, action) => { ... }, + * actionType2: (state, action) => { ... }, + * }, + * }); + * export const { actionType1, actionType2 } = slice.actions; + * ``` + */ + private class CreateSliceAction extends Range { + API::CallNode call; + string actionName; + + CreateSliceAction() { + call = API::moduleImport("@reduxjs/toolkit").getMember("createSlice").getACall() and + this = call.getReturn().getMember("actions").getMember(actionName).getAnImmediateUse() + } + + override string getTypeTag() { + exists(string prefix | + call.getParameter(0).getMember("name").getARhs().mayHaveStringValue(prefix) and + result = prefix + "/" + actionName + ) + } + } + + /** + * An action creator made by a call to `createAsyncThunk`: + * ```js + * const fetchUserId = createAsyncThunk('fetchUserId', async (id) => { + * return (await fetchUserId(id)).data; + * }); + * ``` + */ + private class CreateAsyncThunk extends Range, API::CallNode { + CreateAsyncThunk() { + this = API::moduleImport("@reduxjs/toolkit").getMember("createAsyncThunk").getACall() + } + + override DataFlow::FunctionNode getMiddlewareFunction(boolean async) { + async = true and + result = getParameter(1).getAValueReachingRhs() + } + + override string getTypeTag() { getArgument(0).mayHaveStringValue(result) } + } + } + + /** + * Gets the type tag of an action creator reaching `node`. + */ + private string getAnActionTypeTag(DataFlow::SourceNode node) { + exists(ActionCreator action | + node = action.ref() and + result = action.getTypeTag() + ) + } + + /** Gets the type tag of an action reaching `node`, or the string value of `node`. */ + // Inlined to avoid duplicating `mayHaveStringValue` + pragma[inline] + private string getATypeTagFromNode(DataFlow::Node node) { + node.mayHaveStringValue(result) + or + node.asExpr().(Label).getName() = result + or + result = getAnActionTypeTag(node.getALocalSource()) + } + + /** A data flow node that is used as a reducer. */ + class ReducerArg extends DataFlow::Node { + ReducerArg() { + this = any(StoreCreation c).getReducerArg() + or + this = any(DelegatingReducer r).getStateHandlerArg(_) + or + this = any(DelegatingReducer r).getActionHandlerArg(_) + } + + /** Gets a data flow node that flows to this reducer argument. */ + DataFlow::SourceNode getASource(DataFlow::TypeBackTracker t) { + t.start() and + result = getALocalSource() + or + // Step through forwarding functions + DataFlow::functionForwardingStep(result.getALocalUse(), getASource(t.continue())) + or + // Step through library functions like `redux-persist` + result.getALocalUse() = getASource(t.continue()).(DelegatingReducer).getAPlainHandlerArg() + or + // Step through function composition (usually composed with various state "enhancer" functions) + exists(FunctionCompositionCall compose, DataFlow::CallNode call | + getASource(t.continue()) = call and + call = compose.getACall() and + result.getALocalUse() = [compose.getAnOperandNode(), call.getAnArgument()] + ) + or + exists(DataFlow::TypeBackTracker t2 | result = getASource(t2).backtrack(t2, t)) + } + + /** Gets a data flow node that flows to this reducer argument. */ + DataFlow::SourceNode getASource() { result = getASource(DataFlow::TypeBackTracker::end()) } + + /** + * Holds if the actions dispatched to this reducer have the given type, that is, + * it is created by an action creator that flows to `actionType`, or has `action.type` set to + * the string value of `actionType`. + */ + predicate isActionTypeHandler(DataFlow::Node actionType) { + exists(DelegatingReducer r | + this = r.getActionHandlerArg(actionType) + or + this = r.getStateHandlerArg(_) and + r.getUseSite().isActionTypeHandler(actionType) + ) + } + + /** + * Holds if the actions dispatched to this reducer have the given `action.type` value. + */ + predicate isTypeTagHandler(string actionType) { + exists(DataFlow::Node node | + isActionTypeHandler(node) and + actionType = getATypeTagFromNode(node) + ) + } + + /** + * Holds if this reducer operates on the root state, as opposed to some access path within the state. + */ + predicate isRootStateHandler() { + this = any(StoreCreation c).getReducerArg() + or + exists(DelegatingReducer r | + this = r.getActionHandlerArg(_) and + r.getUseSite().isRootStateHandler() + ) + } + } + + /** + * A source of the `dispatch` function, used as starting point for `getADispatchFunctionReference`. + */ + abstract private class DispatchFunctionSource extends DataFlow::SourceNode { } + + /** + * A value that is dispatched, that is, flows to the first argument of `dispatch` + * (but where the call to `dispatch` is not necessarily explicit in the code). + * + * Used as starting point for `getADispatchedValueSource`. + */ + abstract private class DispatchedValueSink extends DataFlow::Node { } + + private class StoreDispatchSource extends DispatchFunctionSource { + StoreDispatchSource() { this = any(StoreCreation c).ref().getAPropertyRead("dispatch") } + } + + /** Gets a data flow node referring to the `dispatch` function. */ + private DataFlow::SourceNode getADispatchFunctionReference(DataFlow::TypeTracker t) { + t.start() and + result instanceof DispatchFunctionSource + or + // When using the redux-thunk middleware, dispatching a function value results in that + // function being invoked with (dispatch, getState). + // We simply assume redux-thunk middleware is always installed. + t.start() and + result = getADispatchedValueSource().(DataFlow::FunctionNode).getParameter(0) + or + exists(DataFlow::TypeTracker t2 | result = getADispatchFunctionReference(t2).track(t2, t)) + } + + /** Gets a data flow node referring to the `dispatch` function. */ + DataFlow::SourceNode getADispatchFunctionReference() { + result = getADispatchFunctionReference(DataFlow::TypeTracker::end()) + } + + /** Gets a data flow node that is dispatched as an action. */ + private DataFlow::SourceNode getADispatchedValueSource(DataFlow::TypeBackTracker t) { + t.start() and + result = any(DispatchedValueSink d).getALocalSource() + or + t.start() and + result = getADispatchFunctionReference().getACall().getArgument(0).getALocalSource() + or + exists(DataFlow::TypeBackTracker t2 | result = getADispatchedValueSource(t2).backtrack(t2, t)) + } + + /** + * Gets a data flow node that is dispatched as an action, that is, it flows to the first argument of `dispatch`. + */ + DataFlow::SourceNode getADispatchedValueSource() { + result = getADispatchedValueSource(DataFlow::TypeBackTracker::end()) + } + + /** Gets the `action` parameter of a reducer that isn't behind an implied type guard. */ + DataFlow::SourceNode getAnUntypedActionInReducer() { + exists(ReducerArg reducer | + not reducer.isTypeTagHandler(_) and + result = reducer.getASource().(DataFlow::FunctionNode).getParameter(1) + ) + } + + /** A call to `bindActionCreators` */ + private class BindActionCreatorsCall extends API::CallNode { + BindActionCreatorsCall() { + this = + API::moduleImport(["redux", "@reduxjs/toolkit"]).getMember("bindActionCreators").getACall() + } + } + + /** The return value of a function flowing into `bindActionCreators`, seen as a value that is dispatched. */ + private class BindActionDispatchSink extends DispatchedValueSink { + BindActionDispatchSink() { + this = any(BindActionCreatorsCall c).getParameter(0).getAMember().getReturn().getARhs() + } + } + + /** + * Holds if `pred -> succ` is step from an action creation to its use in a reducer function. + */ + predicate actionToReducerStep(DataFlow::Node pred, DataFlow::SourceNode succ) { + // Actions created by an action creator library + exists(ActionCreator action | + exists(DataFlow::CallNode call | call = action.ref().getACall() | + exists(int i | + pred = call.getArgument(i) and + succ = action.getMiddlewareFunction(_).getParameter(i) + ) + or + not exists(action.getMiddlewareFunction(_)) and + pred = call.getArgument(0) and + succ = action.getAPayloadReference() + or + pred = call.getArgument(0) and + succ = action.getAMetaArgReference() + ) + or + pred = action.getMiddlewareFunction(false).getReturnNode() and + succ = action.getAPayloadReference() + ) + or + // Manually created and dispatched actions + exists(string actionType, string prop, DataFlow::SourceNode actionSrc | + actionSrc = getAnUntypedActionInReducer() and + pred = getAManuallyDispatchedValue(actionType).getAPropertyWrite(prop).getRhs() and + succ = actionSrc.getAPropertyRead(prop) + | + getASuccessfulTypeCheckBlock(actionSrc, actionType).dominates(succ.getBasicBlock()) + or + exists(ReducerArg reducer | + reducer.isTypeTagHandler(actionType) and + actionSrc = reducer.getASource().(DataFlow::FunctionNode).getParameter(1) + ) + ) + } + + /** Holds if `pred -> succ` is a step from the promise of an action payload to its use in a reducer function. */ + predicate actionToReducerPromiseStep(DataFlow::Node pred, DataFlow::SourceNode succ) { + exists(ActionCreator action | + pred = action.getMiddlewareFunction(true).getReturnNode() and + succ = action.getAPayloadReference() + ) + } + + private class ActionToReducerStep extends DataFlow::AdditionalFlowStep { + ActionToReducerStep() { + actionToReducerStep(_, this) + or + actionToReducerPromiseStep(_, this) + } + + override predicate step(DataFlow::Node pred, DataFlow::Node succ) { + actionToReducerStep(pred, succ) and succ = this + } + + override predicate loadStep(DataFlow::Node pred, DataFlow::Node succ, string prop) { + actionToReducerPromiseStep(pred, succ) and succ = this and prop = Promises::valueProp() + } + } + + /** Gets the access path which `reducer` operates on. */ + string getAffectedStateAccessPath(ReducerArg reducer) { + exists(DelegatingReducer r | + exists(string prop | reducer = r.getStateHandlerArg(prop) | + result = joinAccessPaths(getAffectedStateAccessPath(r.getUseSite()), prop) + or + r.getUseSite().isRootStateHandler() and + result = prop + ) + or + reducer = r.getActionHandlerArg(_) and + result = getAffectedStateAccessPath(r.getUseSite()) + ) + } + + /** + * Holds if `pred -> succ` should be a step from a reducer to a state access affected by the reducer. + */ + predicate reducerToStateStep(DataFlow::Node pred, DataFlow::Node succ) { + reducerToStateStepAux(pred, succ) and + ProgramSlicing::areFilesInSameReduxApp(pred.getFile(), succ.getFile()) + } + + /** + * Holds if `pred -> succ` should be a step from a reducer to a state access affected by the reducer. + * + * This is a helper predicate for `reducerToStateStep` without the program-slicing check. + */ + pragma[nomagic] + private predicate reducerToStateStepAux(DataFlow::Node pred, DataFlow::SourceNode succ) { + exists(ReducerArg reducer, DataFlow::FunctionNode function, string accessPath | + function = reducer.getASource() and + accessPath = getAffectedStateAccessPath(reducer) + | + pred = function.getReturnNode() and + succ = rootStateAccessPath(accessPath).getAnImmediateUse() + or + exists(string suffix, DataFlow::SourceNode base | + base = [function.getParameter(0), function.getReturnNode().getALocalSource()] and + pred = AccessPath::getAnAssignmentTo(base, suffix) and + succ = rootStateAccessPath(accessPath + "." + suffix).getAnImmediateUse() + ) + ) + or + exists( + ReducerArg reducer, DataFlow::FunctionNode function, string suffix, DataFlow::SourceNode base + | + function = reducer.getASource() and + reducer.isRootStateHandler() and + base = [function.getParameter(0), function.getReturnNode().getALocalSource()] and + pred = AccessPath::getAnAssignmentTo(base, suffix) and + succ = rootStateAccessPath(suffix).getAnImmediateUse() + ) + } + + private class ReducerToStateStep extends DataFlow::AdditionalFlowStep { + ReducerToStateStep() { reducerToStateStep(_, this) } + + override predicate step(DataFlow::Node pred, DataFlow::Node succ) { + reducerToStateStep(pred, succ) and succ = this + } + } + + /** + * Gets a dispatched object literal with a property `type: actionType`. + */ + private DataFlow::ObjectLiteralNode getAManuallyDispatchedValue(string actionType) { + result.getAPropertyWrite("type").getRhs().mayHaveStringValue(actionType) and + result = getADispatchedValueSource() + } + + /** + * Gets the block to be executed after a check has determined that `action.type` is `actionType`, + * or the entry block of a closure dominated by such a check. + */ + private ReachableBasicBlock getASuccessfulTypeCheckBlock( + DataFlow::SourceNode action, string actionType + ) { + action = getAnUntypedActionInReducer() and + ( + exists(MembershipCandidate candidate, ConditionGuardNode guard | + action.getAPropertyRead("type").flowsTo(candidate) and + candidate.getAMemberString() = actionType and + guard.getTest() = candidate.getTest().asExpr() and + guard.getOutcome() = candidate.getTestPolarity() and + result = guard.getBasicBlock() + ) + or + exists(SwitchStmt switch, SwitchCase case | + action.getAPropertyRead("type").flowsTo(switch.getExpr().flow()) and + case = switch.getACase() and + case.getExpr().mayHaveStringValue(actionType) and + result = getCaseBlock(case) + ) + ) + or + exists(Function f | + getASuccessfulTypeCheckBlock(action, actionType) + .dominates(f.(ControlFlowNode).getBasicBlock()) and + result = f.getEntryBB() + ) + } + + /** Gets the block to execute when `case` matches sucessfully. */ + private BasicBlock getCaseBlock(SwitchCase case) { + result = case.getBodyStmt(0).getBasicBlock() + or + not exists(case.getABodyStmt()) and + exists(SwitchStmt stmt, int i | + stmt.getCase(i) = case and + result = getCaseBlock(stmt.getCase(i + 1)) + ) + } + + /** + * Defines a flow step to be used for propagating tracking access to `state`. + * + * An `AdditionalFlowStep` is generated for these steps as well. + * It is distinct from `AdditionalFlowStep` to avoid recursion between that and the propagation of `state`. + */ + private class StateStep extends Unit { + abstract predicate step(DataFlow::Node pred, DataFlow::Node succ); + } + + private predicate stateStep(DataFlow::Node pred, DataFlow::Node succ) { + any(StateStep s).step(pred, succ) + } + + private class StateStepAsFlowStep extends DataFlow::AdditionalFlowStep { + StateStepAsFlowStep() { stateStep(_, this) } + + override predicate step(DataFlow::Node pred, DataFlow::Node succ) { + stateStep(pred, succ) and succ = this + } + } + + /** + * Model of the `react-redux` package. + */ + private module ReactRedux { + /** Gets an API node referring to the `useSelector` function. */ + API::Node useSelector() { result = API::moduleImport("react-redux").getMember("useSelector") } + + /** + * Step out of a `useSelector` call, such as from `state.x` to the result of `useSelector(state => state.x)`. + */ + class UseSelectorStep extends StateStep { + override predicate step(DataFlow::Node pred, DataFlow::Node succ) { + exists(API::CallNode call | + call = useSelector().getACall() and + pred = call.getParameter(0).getReturn().getARhs() and + succ = call + ) + } + } + + /** The argument to a `useSelector` callback, seen as a root state reference. */ + class UseSelectorStateSource extends RootStateSource { + UseSelectorStateSource() { this = useSelector().getParameter(0).getParameter(0) } + } + + /** A call to `useDispatch`, as a source of the `dispatch` function. */ + private class UseDispatchFunctionSource extends DispatchFunctionSource { + UseDispatchFunctionSource() { + this = + API::moduleImport("react-redux").getMember("useDispatch").getReturn().getAnImmediateUse() + } + } + + /** + * A call to `connect()`, typically as part of a code pattern like the following: + * ```js + * let withConnect = connect(mapStateToProps, mapDispatchToProps); + * let MyAwesomeComponent = compose(withConnect, otherStuff)(MyComponent); + * ``` + */ + abstract private class ConnectCall extends API::CallNode { + /** Gets the API node corresponding to the `mapStateToProps` argument. */ + abstract API::Node getMapStateToProps(); + + /** Gets the API node corresponding to the `mapDispatchToProps` argument. */ + abstract API::Node getMapDispatchToProps(); + + /** + * Gets a function whose first argument becomes the React component to connect. + */ + DataFlow::SourceNode getAComponentTransformer() { + result = this + or + exists(FunctionCompositionCall compose | + getAComponentTransformer().flowsTo(compose.getAnOperandNode()) and + result = compose + ) + } + + /** + * Gets a data-flow node that should flow to `props.name` via the `mapDispatchToProps` function. + */ + DataFlow::Node getDispatchPropNode(string name) { + // Implicitly bound by bindActionCreators: + // + // const mapDispatchToProps = { foo } + // + result = getMapDispatchToProps().getMember(name).getARhs() + or + // + // const mapDispatchToProps = dispatch => ( { foo } ) + // + result = getMapDispatchToProps().getReturn().getMember(name).getARhs() + or + // Explicitly bound by bindActionCreators: + // + // const mapDispatchToProps = dispatch => bindActionCreators({ foo }, dispatch); + // + exists(BindActionCreatorsCall bind | + bind.flowsTo(getMapDispatchToProps().getReturn().getARhs()) and + result = bind.getOptionArgument(0, name) + ) + } + + /** + * Gets the React component decorated by this call, if one can be determined. + */ + ReactComponent getReactComponent() { + exists(DataFlow::SourceNode component | component = result.getAComponentCreatorReference() | + component.flowsTo(getAComponentTransformer().getACall().getArgument(0)) + or + component.(DataFlow::ClassNode).getADecorator() = getAComponentTransformer() + ) + } + } + + /** A call to `connect`. */ + private class RealConnectFunction extends ConnectCall { + RealConnectFunction() { + this = API::moduleImport("react-redux").getMember("connect").getACall() + } + + override API::Node getMapStateToProps() { result = getParameter(0) } + + override API::Node getMapDispatchToProps() { result = getParameter(1) } + } + + /** + * An entry point in the API graphs corresponding to functions named `mapDispatchToProps`, + * used to catch cases where the call to `connect` was not found (usually because of it being + * wrapped in another function, which API graphs won't look through). + */ + private class HeuristicConnectEntryPoint extends API::EntryPoint { + HeuristicConnectEntryPoint() { this = "react-redux-connect" } + + override DataFlow::Node getARhs() { none() } + + override DataFlow::SourceNode getAUse() { + exists(DataFlow::CallNode call | + call.getAnArgument().asExpr().(Identifier).getName() = + ["mapStateToProps", "mapDispatchToProps"] and + // exclude genuine calls to avoid duplication + not call = DataFlow::moduleMember("react-redux", "connect").getACall() and + result = call.getCalleeNode().getALocalSource() + ) + } + } + + /** A heuristic call to `connect`, recognized by it taking arguments named `mapStateToProps` and `mapDispatchToProps`. */ + private class HeuristicConnectFunction extends ConnectCall { + HeuristicConnectFunction() { + this = API::root().getASuccessor(any(HeuristicConnectEntryPoint e)).getACall() + } + + override API::Node getMapStateToProps() { + result = getAParameter() and + result.getARhs().asExpr().(Identifier).getName() = "mapStateToProps" + } + + override API::Node getMapDispatchToProps() { + result = getAParameter() and + result.getARhs().asExpr().(Identifier).getName() = "mapDispatchToProps" + } + } + + /** + * A step from the return value of `mapStateToProps` to a `props` access. + */ + private class StateToPropsStep extends StateStep { + override predicate step(DataFlow::Node pred, DataFlow::Node succ) { + exists(ConnectCall call | + pred = call.getMapStateToProps().getReturn().getARhs() and + succ = call.getReactComponent().getADirectPropsAccess() + ) + } + } + + /** + * Holds if `pred -> succ` is a step from `mapDispatchToProps` to a `props` property access. + */ + predicate dispatchToPropsStep(DataFlow::Node pred, DataFlow::Node succ) { + exists(ConnectCall call, string member | + pred = call.getDispatchPropNode(member) and + succ = call.getReactComponent().getAPropRead(member) + ) + } + + /** The first argument to `mapDispatchToProps` as a source of the `dispatch` function */ + private class MapDispatchToPropsArg extends DispatchFunctionSource { + MapDispatchToPropsArg() { + this = any(ConnectCall c).getMapDispatchToProps().getParameter(0).getAnImmediateUse() + } + } + + /** If `mapDispatchToProps` is an object, each method's return value is dispatched. */ + private class MapDispatchToPropsMember extends DispatchedValueSink { + MapDispatchToPropsMember() { + this = any(ConnectCall c).getMapDispatchToProps().getAMember().getReturn().getARhs() + } + } + + /** The first argument to `mapStateToProps` as an access to the root state. */ + private class MapStateToPropsStateSource extends RootStateSource { + MapStateToPropsStateSource() { + this = any(ConnectCall c).getMapStateToProps().getParameter(0) + } + } + } + + private module Reselect { + /** + * A call to `createSelector`. + * + * Such calls have two forms. The single-argument version is simply a memoized function wrapper: + * + * ```js + * createSelector(state => state.foo) + * ``` + * + * If multiple arguments are used, each callback independently maps over the state, and last + * callback collects all the intermediate results into the final result: + * + * ```js + * creatorSelector( + * state => state.foo, + * state => state.bar, + * ([foo, bar]) => {...} + * ) + * ``` + * + * Although selectors can work on any data, not just the Redux state, they are in practice only used + * with the state. + */ + class CreateSelectorCall extends API::CallNode { + CreateSelectorCall() { + this = + API::moduleImport(["reselect", "@reduxjs/toolkit"]).getMember("createSelector").getACall() + } + + /** Gets the `i`th selector callback, that is, a callback other than the result function. */ + API::Node getSelectorFunction(int i) { + // When there are multiple callbacks, exclude the last one + result = getParameter(i) and + (i = 0 or i < getNumArgument() - 1) + or + // Selector functions may be given as an array + exists(DataFlow::ArrayCreationNode array | + array.flowsTo(getArgument(0)) and + result.getAUse() = array.getElement(i) + ) + } + } + + /** The state argument to a selector */ + private class SelectorStateArg extends RootStateSource { + SelectorStateArg() { this = any(CreateSelectorCall c).getSelectorFunction(_).getParameter(0) } + } + + /** A flow step between the callbacks of `createSelector` or out of its final selector. */ + private class CreateSelectorStep extends StateStep { + override predicate step(DataFlow::Node pred, DataFlow::Node succ) { + // Return value of `i`th callback flows to the `i`th parameter of the last callback. + exists(CreateSelectorCall call, int index | + call.getNumArgument() > 1 and + pred = call.getSelectorFunction(index).getReturn().getARhs() and + succ = call.getLastParameter().getParameter(index).getAnImmediateUse() + ) + or + // The result of the last callback is the final result + exists(CreateSelectorCall call | + pred = call.getLastParameter().getReturn().getARhs() and + succ = call + ) + } + } + } +} diff --git a/javascript/ql/test/library-tests/frameworks/Redux/exportedReducer.js b/javascript/ql/test/library-tests/frameworks/Redux/exportedReducer.js new file mode 100644 index 00000000000..4331d0f2157 --- /dev/null +++ b/javascript/ql/test/library-tests/frameworks/Redux/exportedReducer.js @@ -0,0 +1,13 @@ +import { combineReducers } from 'redux'; + +export default (state, action) => { + return state; +}; + +export function notAReducer(notState, notAction) { + console.log(notState, notAction); +} + +export const nestedReducer = combineReducers({ + inner: (state, action) => state +}); diff --git a/javascript/ql/test/library-tests/frameworks/Redux/react-redux.jsx b/javascript/ql/test/library-tests/frameworks/Redux/react-redux.jsx new file mode 100644 index 00000000000..abb9729d4bf --- /dev/null +++ b/javascript/ql/test/library-tests/frameworks/Redux/react-redux.jsx @@ -0,0 +1,79 @@ +import * as React from 'react'; +import { connect, useDispatch } from 'react-redux'; +import * as rt from '@reduxjs/toolkit'; + +const toolkitAction = rt.createAction('toolkitAction', (x) => { + return { + toolkitValue: x + } +}); +const toolkitReducer = rt.createReducer({}, builder => { + builder + .addCase(toolkitAction, (state, action) => { + return { + value: action.payload.toolkitValue, + ...state + }; + }) + .addCase(asyncAction.fulfilled, (state, action) => { + return { + asyncValue: action.payload.x, + ...state + }; + }); +}); + +function manualAction(x) { + return { + type: 'manualAction', + payload: x + } +} +function manualReducer(state, action) { + switch (action.type) { + case 'manualAction': { + return { ...state, manualValue: action.payload }; + } + } + return state; +} +const asyncAction = rt.createAsyncThunk('asyncAction', (x) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ x }); + }, 10) + }); +}); + +const store = rt.createStore(rt.combineReducers({ + toolkit: toolkitReducer, + manual: manualReducer, +})); + +function MyComponent(props) { + let dispatch = useDispatch(); + const clickHandler = React.useCallback(() => { + props.toolkitAction(source()); + props.manualAction(source()); // not currently propagated as functions are not type-tracked + dispatch(manualAction(source())); + dispatch(asyncAction(source())); + }); + + sink(props.propFromToolkitAction); // NOT OK + sink(props.propFromManualAction); // NOT OK + sink(props.propFromAsync); // NOT OK + + return