Skip to content

Commit

Permalink
docs: added sections on security (#57)
Browse files Browse the repository at this point in the history
  • Loading branch information
fhur authored Oct 10, 2024
1 parent 5df8937 commit 10826ad
Show file tree
Hide file tree
Showing 6 changed files with 291 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@ describe('PgExecutor', () => {
`);
});

it('limit sql injection', () => {
expect(() => {
const query = from('actor')
.limit(`1; drop table actor; --` as unknown as number)
.all();
executor.compile(query);
}).toThrow('Expected limit to be a number');
});

it('offset sql injection', () => {
expect(() => {
const query = from('actor')
.offset(`1; drop table actor; --` as unknown as number)
.all();
executor.compile(query);
}).toThrow('Expected offset to be a number');
});

it('Film table SynthQL query executes to expected result', async () => {
const result = await executor.execute(q1, executeProps);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function assertPrimitive(x: unknown, msg?: string): asserts x is Primitive {
if (!isPrimitive(x)) {
throw new Error(
msg ??
`Expected ${JSON.stringify(x)} to be a primitive but was ${typeof x}`,
`Expected ${JSON.stringify(x)} to be a primitive but was ${typeof x}`,
);
}
}
Expand Down Expand Up @@ -352,7 +352,10 @@ export class SqlBuilder {
if (limit === undefined) {
return this;
}
return this.add(`limit ${limit} `);
if (typeof limit !== 'number') {
throw new Error(`Expected limit to be a number`);
}
return this.add(`limit ${Number(limit)} `);
}

leftJoin(join: Join) {
Expand Down Expand Up @@ -401,7 +404,10 @@ export class SqlBuilder {
if (offset === undefined) {
return this;
}
return this.add(`offset ${offset} `);
if (typeof offset !== 'number') {
throw new Error(`Expected offset to be a number`);
}
return this.add(`offset ${Number(offset)} `);
}

build() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ This behaviour can be disabled with the `allowUnknownQueries` option.
const queryEngine = new QueryEngine({..., allowUnknownQueries:true});
```

You can read more about registered queries [here](/docs/security/registered-queries).

## Restricting access to tables and columns

You can use the `.requires` method to define what permissions are required to run the query.
Expand Down
44 changes: 44 additions & 0 deletions packages/docs/docs/200-security/query-middleware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Query middlewares

Query middlewares are functions that are executed before a query is executed. They can be used to add additional functionality to the query, such as logging, caching, or authentication.

In the context of security, query middlewares can be used to add additional checks on every query, or add additional filters to the query to limit the result set.

## Adding a middleware

You can add a middleware to the query engine using the `.use()` method.

```ts
import { DB } from './db';
import { QueryEngine, mapQuery } from '@synthql/backend';
import { orders } from './queries';

const restrictOrdersByUser = middleware<DB>()
.from('orders')
.mapQuery((query, context) => {
const userId = context.user.id;
return {
context,
query: {
...query,
// transforms the `where` to ensure that only orders can be read from the
// current user.
where: {
...query.where,
user_id: userId,
},
},
};
});

const queryEngine = new QueryEngine<DB>({
middlewares: [restrictOrdersByUser],
});
```

## When are middlewares executed?

When a query is executed, the ID check is performed first, and then the parameters are substituted. Then the middleware is executed.

This ensures that the middleware can inject additional parameters to the query as it's now happening in a safe context.

69 changes: 69 additions & 0 deletions packages/docs/docs/200-security/query-permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Query permissions

SynthQL uses a declarative approach to define what permissions are required to run a query. This approach is both simple and powerful, and makes it easy to understand what permissions are required to run a query.

With SynthQL you don't need to sprinkle your code with permission assertions or `if` conditions to check for permissions, instead you define on a per-query basis what permissions are required to run the query, and the QueryEngine will take care of the rest.

## Defining permissions

The `.requires()` method is used to define what permissions are required to run a query.

```ts
from('users')
.requires('users:read')
.all()
```

The `.requires(...roles:string[])` method takes a list of roles. Roles can be any string.

```ts
from('users')
.requires('users:read', 'users:write')
.all()
```

You can use an TypeScript enum to define the list of permissions and get extra type safety:

```ts
enum Permissions {
usersRead = 'users:read',
usersWrite = 'users:write',
}

from('users')
.requires(Roles.usersRead, Roles.usersWrite)
.all()
```

## Role inheritance

When you include a sub-query, the permissions add up. This means the user needs to have all the permissions of both the parent and sub-query to be able to execute.

```ts
const pets = from('pets')
.requires('pets:read')
.where({ owner_id: col('users.id') })
.all()

from('users')
.requires('users:read')
.include({ pets })
.all()
```

In this example, the user needs to have the `users:read` and `pets:read` permissions to execute the query.

## Query context

When you execute a query, you can pass a `context` object. This object is used to pass additional information to the query, such as the user's permissions.

```ts
const context = { permissions: ['users:read', 'pets:read'] };
queryEngine.execute(query, { context });
```

The query engine will traverse the query recursively and reject the query unless it meets all the ACL requirements.

## Query registration

When a query is registered, it is registered along with its permissions. This means a malicious client cannot modify the ACL requirements of a query.
149 changes: 149 additions & 0 deletions packages/docs/docs/200-security/registered-queries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Registered queries

One of the core security goals of SynthQL is to be out-of-the-box secure. This means that by default the QueryEngine will not execute unknown queries. Queries need to be explicitly registered with the `QueryEngine` for them to be executed.

```ts
const findAllActiveUsers = () => from('users')
.columns('id','name','email')
.filter({active: true})
.all()

const queryEngine = new QueryEngine({...})

// Register the query
queryEngine.registerQuery(findAllActiveUsers())

// The QueryEngine will now only execute the registered queries
queryEngine.execute(findAllActiveUsers(), { context })
```

## Why registered queries?

Registered queries are a security feature that ensures that only known queries are executed. This prevents a potentially malicious actor from executing arbitrary queries on your database.

When you build a traditional REST API, you implicitly "register queries" by defining the endpoints that are available. As SynthQL is more dynamic, we need an explicit mechanism to indicate that a query was authored in a safe context.

## How to register queries

Registering queries is simple. All you need to do is pass the query to the `QueryBuilder#registerQueries` or `QueryEngine#registerQuery` methods.

As some queries take parameters, you will need to pass a placeholder value when you register the query.

```ts
import { param } from '@synthql/queries'
import { from } from "../generated";

const findUserById = (id: number) => from('users')
.columns('id','name','email')
.filter({id})
.first()

const queryEngine = new QueryEngine({...})

// Notice that we are passing a placeholder value of `0` for the
// `id` parameter. We could have passed any value, it is essentially
// telling the QueryEngine that the `id` is a parameter for the
// query, and can be replaced with any value.
queryEngine.registerQuery(findUserById(0))

// You can now invoke the query with any value
queryEngine.execute(findUserById(anyUserId))
```

## Queries with conditional logic

Some queries may have conditional logic. For example, you may want to alter the structure of the query based on the value of a parameter.

```ts
const findUsersByStatus = (status: 'active' | 'inactive') => {
const query = from('users')
.columns('id','name','email')
.filter({status})

if (status === 'active') {
// If the user is active, we want to return all users
return query.all()
} else {
// If the user is not active, we want to return only
// the first 100 users as there might be too many.
return query.take(100)
}
}
```

The problem with these types of queries is that they actually return two different query instances depending on the value of the `status` parameter.

To register these types of queries correctly, you will need to register each variant of the query individually.

```ts
const queryEngine = new QueryEngine({...})

queryEngine.registerQueries([
findUsersByStatus('active'),
findUsersByStatus('inactive')
])
```

## The security of registered queries

> Note: this is an advanced topic. You don't need to understand this section to use SynthQL.
As a lot of the security of SynthQL depends on the registered queries, it is important to understand how they work.

The high level idea is simple:

1. Identifying queries: When a query is registered, we calculate a hash of the query. This hash is then used to identify the query.
2. Checking queries: When a query is executed, the QueryEngine will check the hash of the query to ensure that it has been registered.
3. Parameter substitution: When a query hash matches, the parameter values are substituted and the query is executed.

### Identifying queries

Every query is given a hash upon creation. The combination of `hash` and `name` is used to identify the query in the QueryEngine.

When a query is registered, the QueryEngine calculates an ID based on the `hash` and `name` and stores it in memory.

### Checking queries

Whenever a query is executed, the QueryEngine will calculate the ID of the query and check if it has been registered. If it has, the query is executed. If it has not, the query is rejected.

### Parameter substitution

When a query is executed, the QueryEngine will substitute the parameter values into the query and execute it.

Example:

```ts
const findUserById = (id: number) => from('users').filter({id}).first()

// The QueryEngine will substitute the parameter values into the query and execute it.
queryEngine.registerQuery(findUserById(0))


```

### What happens in case of a hash collision?

If you try to register two different queries with the same hash, the QueryEngine will throw an error.

If a malicious user tries to execute a query A with the hash of query B, it would be equivalent to simply trying to execute query B.

### How is the hash calculated?

The hash is calculated by `JSON.stringify`ing the query, and then hashing the result. We hash the stringified query to avoid sending possibly very large JSON strings over the wire. Special care is taken to ensure that the parts of the query that are parameterizable are not hardcoded in the hash.

So for example, the following two queries will have the same hash:

```ts
const queryA = from('users').filter({active: true}).all()
const queryB = from('users').filter({active: false}).all()
```

But these two will not:

```ts
const queryC = from('users').columns('id','name','email').filter({active: true}).all()
const queryD = from('users').columns('email').filter({active: true}).all()
```

The Query hash is calculated by the [`hashQuery`](https://github.com/synthql/SynthQL/blob/master/packages/queries/src/util/hashQuery.ts#L9) function.

0 comments on commit 10826ad

Please sign in to comment.