Skip to content
This repository has been archived by the owner on Aug 1, 2023. It is now read-only.

Validate updatedAt on saves and deletes #30

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
test/*
4 changes: 4 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ module.exports = {
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
project: {
extends: './tsconfig.json',
include: ['src/**/*', 'test/**/*'],
},
},
env: {
'jest/globals': true,
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Radiks-Server Changelog

## 0.3.0-beta.1 - November 18th, 2019

Fixes for [#29](https://github.com/blockstack/radiks-server/issues/29)

Radiks-server was not properly validating the `updatedAt` attribute for model updates and deletes. This could potentially lead to signature jacking. Radiks-server now validated that the `updatedAt` field is greater than previous `updatedAt`s. This acts similarly to an `nonce` in blockchains.

## 0.2.3 - September 11th, 2019

- Dependency updates from @dependabot:
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "radiks-server",
"version": "0.2.3",
"version": "0.3.0-beta.1",
"description": "An express plugin for building a Radiks server",
"main": "app/index.js",
"types": "typed",
Expand All @@ -15,7 +15,7 @@
"compile-watch": "babel src -d app --watch --extensions '.ts,.js'",
"typescript": "tsc",
"prettier": "prettier --write '**/*.{ts,tsx,json}'",
"eslint": "eslint 'src/**/*'",
"eslint": "eslint 'src/**/*' 'test/**/*' --ext .ts",
"test": "jest --verbose=true --ci --runInBand test/",
"test-watch": "jest --verbose=true --ci --runInBand --watch test/",
"prepublishOnly": "yarn build"
Expand Down
14 changes: 12 additions & 2 deletions src/controllers/ModelsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,18 @@ const makeModelsController = (
_id: attrs.signingKeyId,
radiksType: 'SigningKey',
});
const message = `${attrs._id}-${attrs.updatedAt}`;
if (verifyECDSA(message, publicKey, req.query.signature)) {
const {
signature,
updatedAt,
}: { signature: string; updatedAt: string } = req.query;
if (!updatedAt || parseInt(updatedAt, 10) <= attrs.updatedAt) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be an ignorant question --
I can't tell if the signature validation encompasses this updatedAt value, is the value from the client already validated before this function?
As in, is there a server-side component preventing a malicious client from just setting this value to Number.MAX_SAFE_INTEGER or something like that?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no such check for updatedAt. We should add a validator check that the value is near the server's time.

return res.json({
success: false,
error: 'Invalid `updatedAt` request query',
});
}
const message = `${attrs._id}-${updatedAt}`;
if (verifyECDSA(message, publicKey, signature)) {
await radiksCollection.deleteOne({ _id: req.params.id });
return res.json({
success: true,
Expand Down
23 changes: 20 additions & 3 deletions src/lib/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ const errorMessage = (message: string) => {
};

class Validator {
private db: Collection;
db: Collection;

private attrs: any;
attrs: any

private previous: any;
previous: any;

constructor(db: Collection, attrs: any) {
this.db = db;
Expand All @@ -22,6 +22,7 @@ class Validator {
await this.fetchPrevious();
await this.validateSignature();
await this.validatePrevious();
await this.validateUpdatedAt();
return true;
}

Expand Down Expand Up @@ -79,6 +80,22 @@ class Validator {
}
}

validateUpdatedAt() {
if (!this.previous) {
return true;
}
if (typeof this.previous.updatedAt !== "number") {
errorMessage("This model's previous `updatedAt` is not a number");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The errorMessage function call resulting in a thrown Error is a surprising side-effect that made this diff hard to reason about. I'd recommend something more like

throw createError('...')

}
if (typeof this.attrs.updatedAt !== "number") {
errorMessage("This model's `updatedAt` is not a number");
}
if (this.attrs.updatedAt <= this.previous.updatedAt) {
errorMessage("Model's `updatedAt` has not been increased");
}
return true;
}

validatePresent(key: string) {
if (!this.attrs[key]) {
errorMessage(`No '${key}' attribute, which is required.`);
Expand Down
33 changes: 30 additions & 3 deletions test/controllers/models-controller.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import '../setup';
import request from 'supertest';
import { Express } from 'express';
import { signECDSA } from 'blockstack/lib/encryption';
import { makeECPrivateKey } from 'blockstack/lib/keys';
import getApp from '../test-server';
Expand All @@ -12,6 +13,7 @@ jest.mock(
'../../src/lib/validator',
() =>
class FakeValidator {
// eslint-disable-next-line class-methods-use-this
validate() {
return true;
}
Expand Down Expand Up @@ -44,7 +46,7 @@ test('it can save the same model twice', async () => {
expect(response.body.success).toEqual(true);
});

const getDocs = async (app, query) => {
const getDocs = async (app: Express, query: any) => {
const req = request(app)
.get('/radiks/models/find')
.query(query);
Expand Down Expand Up @@ -99,13 +101,14 @@ test('it can delete a model', async () => {
const radiksData = db.collection(constants.COLLECTION);
await signer.save(db);
await radiksData.insertOne(model);
signer.sign(model);
const { signature } = signECDSA(
signer.privateKey,
`${model._id}-${model.updatedAt}`
);
const response = await request(app)
.del(`/radiks/models/${model._id}`)
.query({ signature });
.query({ signature, updatedAt: model.updatedAt });
expect(response.body.success).toEqual(true);
const dbModel = await radiksData.findOne({ _id: model._id });
expect(dbModel).toBeNull();
Expand All @@ -120,14 +123,38 @@ test('it cannot delete with an invalid signature', async () => {
const radiksData = db.collection(constants.COLLECTION);
await signer.save(db);
await radiksData.insertOne(model);
const updatedAt = (model.updatedAt as number) + 1;
const { signature } = signECDSA(
makeECPrivateKey(),
`${model._id}-${updatedAt}`
);
const response = await request(app)
.del(`/radiks/models/${model._id}`)
.query({ signature, updatedAt });
expect(response.body.success).toEqual(false);
expect(response.body.error).toEqual('Invalid signature');
const dbModel = await radiksData.findOne({ _id: model._id });
expect(dbModel).not.toBeNull();
});

test('it cannot update with an older updatedAt', async () => {
const app = await getApp();
const model = { ...models.test1 };
const signer = new Signer();
signer.sign(model);
const db = await getDB();
const radiksData = db.collection(constants.COLLECTION);
await signer.save(db);
await radiksData.insertOne(model);
const { signature } = signECDSA(
signer.privateKey,
`${model._id}-${model.updatedAt}`
);
const response = await request(app)
.del(`/radiks/models/${model._id}`)
.query({ signature });
.query({ signature, updatedAt: model.updatedAt });
expect(response.body.success).toEqual(false);
expect(response.body.error).toEqual('Invalid `updatedAt` request query');
const dbModel = await radiksData.findOne({ _id: model._id });
expect(dbModel).not.toBeNull();
});
Expand Down
14 changes: 13 additions & 1 deletion test/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,19 @@ import constants from '../src/lib/constants';

const userGroupId = faker.random.uuid();

const models = {
interface MockModel {
_id: string;
updatedAt?: number;
createdAt?: number;
signingKeyId?: string;
[key: string]: any;
}

interface Models {
[key: string]: MockModel;
}

const models: Models = {
test1: {
name: faker.name.findName(),
email: faker.internet.email(),
Expand Down
6 changes: 5 additions & 1 deletion test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ dotenv.config({
path: path.resolve(process.cwd(), '.env.test'),
});

jest.mock('request-promise', () => options => {
interface RequestPromiseArgs {
uri: string;
}

jest.mock('request-promise', () => (options: RequestPromiseArgs) => {
const { models } = require('./mocks'); // eslint-disable-line
const { uri } = options;
return Promise.resolve(models[uri]);
Expand Down
14 changes: 9 additions & 5 deletions test/signer.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
/* eslint-disable no-param-reassign */
import { makeECPrivateKey, getPublicKeyFromPrivate } from 'blockstack/lib/keys';
import { signECDSA } from 'blockstack/lib/encryption';
import uuid from 'uuid/v4';
import { Db } from 'mongodb';
import constants from '../src/lib/constants';

export default class Signer {
private _id: string;
private privateKey: string;
private publicKey: string;
_id: string;

privateKey: string;

publicKey: string;

constructor(privateKey?: string) {
this.privateKey = privateKey || makeECPrivateKey();
this.publicKey = getPublicKeyFromPrivate(this.privateKey);
this._id = uuid();
}

save(db) {
save(db: Db) {
const { _id, privateKey, publicKey } = this;
return db.collection(constants.COLLECTION).insertOne({
_id,
Expand All @@ -24,7 +28,7 @@ export default class Signer {
});
}

sign(doc) {
sign(doc: any) {
const now = new Date().getTime();
doc.updatedAt = now;
doc.signingKeyId = doc.signingKeyId || this._id;
Expand Down
21 changes: 19 additions & 2 deletions test/validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ test('it doesnt allow mismatched signingKeyId', async () => {
...models.hank,
};
signer.sign(model);
await db.collection(constants.COLLECTION).insertOne(model);
let validator = new Validator(db.collection(constants.COLLECTION), model);
expect(await validator.validate()).toEqual(true);
await db.collection(constants.COLLECTION).insertOne(model);

const secondSigner = new Signer();
await secondSigner.save(db);
Expand Down Expand Up @@ -61,7 +61,7 @@ test('it allows changing the signing key if signed with previous signing key', a
expect(await validator.validate()).toEqual(true);
});

test('it doesnt allow older updatedAt', async () => {
test('it doesnt allow updating if `updatable` is false', async () => {
const model = {
...models.notUpdatable,
};
Expand Down Expand Up @@ -148,3 +148,20 @@ test('allows users to use personal signing key', async () => {
const validator = new Validator(db.collection(constants.COLLECTION), user);
expect(await validator.validate()).toEqual(true);
});

test('doesnt allow updating if updatedAt hasnt changed', async () => {
const model = {
...models.hank,
};
const signer = new Signer();
const db = await getDB();
await signer.save(db);
signer.sign(model);
let validator = new Validator(db.collection(constants.COLLECTION), model);
expect(await validator.validate()).toBe(true);
await db.collection(constants.COLLECTION).insertOne(model);
validator = new Validator(db.collection(constants.COLLECTION), model);
await expect(validator.validate()).rejects.toThrow(
"Model's `updatedAt` has not been increased"
);
});