Skip to content
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

storage: add getSignedPolicy function to generate a signed policy #502

Closed
wants to merge 10 commits into from
139 changes: 139 additions & 0 deletions lib/storage/file.js
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,145 @@ File.prototype.getSignedUrl = function(options, callback) {
});
};

/**
* Get a signed policy document to allow user to upload data
* with a POST.
*
* *[Reference](http://goo.gl/JWJEkG).*
*
* @throws {Error} if an expiration timestamp from the past is given or
* option parameter does not respect

This comment was marked as spam.

This comment was marked as spam.

*
* @param {object} options - Configuration object.
* @param {object} options.expiration - Timestamp (seconds since epoch)
* when this policy will expire.
* @param {object[][]=} options.equals - Array of request parameters and
* their expected value (e.g. [["$<field>", "<value>"]]). Values are

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

* translated into equality constraints in the conditions
* field of the policy document (e.g. ["eq", "$<field>", "<value>"])
* @param {object[][]=} options.startsWith - Array of request parameters and
* their expected prefixes (e.g. [["$<field>", "<value>"]]). Values are
* translated into starts-with constraints in the conditions field
* of the policy document (e.g. ["starts-with", "$<field>", "<value>"])
* @param {string=} options.acl - ACL for the object from possibly predefined
* ACLs
* @param {string=} options.successRedirect - The URL to which the user
* client is redirected if the upload is successfull
* @param {string=} options.successStatus - The status of the Google Storage
* response if the upload is successfull (must be string)
* @param {object=} options.contentLengthRange - Object providing
* minimum (options.contentLengthRange.min) and maximum
* (options.contentLengthRange.max) value for the request's
* content length
*
* @example
* file.getSignedPolicy({
* equals: [["$Content-Type", "image/jpeg"]],

This comment was marked as spam.

* contentLengthRange: {min: 0, max: 1024},
* expiration: Math.round(Date.now() / 1000) + (60 * 60 * 24 * 14) // 2 weeks.
* }, function(err, policy) {
* // policy.string: the policy document, plain text
* // policy.base64: the policy document, base64
* // policy.signature: the policy signature, base64
* });
*/
File.prototype.getSignedPolicy = function(options, callback) {
if (options.expiration < Math.floor(Date.now() / 1000)) {
throw new Error('An expiration date cannot be in the past.');
}

var expirationString = new Date(options.expiration).toISOString();
var conditions = [];

// Populate eq conditions
if (options.equals && options.equals instanceof Array) {

This comment was marked as spam.

var equalsLength = options.equals.length;
for (var i = 0; i < equalsLength; i++) {
var equalCondition = options.equals[i];
if ((!(equalCondition instanceof Array)) || equalCondition.length !== 2) {
throw new Error('Equals condition must be an array of 2 elements.');
} else {
conditions.push(['eq', equalCondition[0], equalCondition[1]]);
}
}
}

This comment was marked as spam.

This comment was marked as spam.


// Populate starts-with conditions
if (options.startsWith && options.startsWith instanceof Array) {
var startsWithLength = options.startsWith.length;
for (var j = 0; j < startsWithLength; j++) {
var startCondition = options.startsWith[j];
if (!(startCondition instanceof Array) || startCondition.length !== 2) {
throw new Error('StartsWith condition must be an array of 2 elements.');
} else {
conditions.push(['starts-with', startCondition[0], startCondition[1]]);
}
}
}

// Eq condition for object name
conditions.push(['eq', '$key', this.name]);

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.


// The bucket that will hold the object
conditions.push({'bucket': this.bucket.name});

This comment was marked as spam.


// The ACL for the file, if any provided
if (options.acl) {
conditions.push({'acl': options.acl});

This comment was marked as spam.

}

// The redirect URL in case of successfull upload
if (options.successRedirect) {
conditions.push({'success_action_redirect': options.successRedirect});
}

// The response status in case of successful upload
if (options.successStatus) {
conditions.push({'success_action_status': options.successStatus});
}

// Range for the content length
if (options.contentLengthRange) {
var min = options.contentLengthRange.min;
var max = options.contentLengthRange.max;
if (typeof min === 'undefined' ||
typeof max === 'undefined') {

This comment was marked as spam.

throw new Error('ContentLengthRange must have min and max fields.');
}
conditions.push(['content-length-range', min, max]);
}

// Policy object
var policy = {
expiration: expirationString,
conditions: conditions
};

var makeAuthorizedRequest_ = this.bucket.storage.makeAuthorizedRequest_;

makeAuthorizedRequest_.getCredentials(function(err, credentials) {
if (err) {
callback(err);
return;
}

var sign = crypto.createSign('RSA-SHA256');
var policyString = JSON.stringify(policy);
var policyBase64 = new Buffer(policyString).toString('base64');

sign.update(policyBase64);

var signature = sign.sign(credentials.private_key, 'base64');

callback(null, {
string: policyString,
base64: policyBase64,
signature: signature
});
});
};

This comment was marked as spam.



/**
* Set the file's metadata.
*
Expand Down
168 changes: 168 additions & 0 deletions test/storage/file.js
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,174 @@ describe('File', function() {
});
});

describe('getSignedPolicy', function() {
var credentials = require('../testdata/privateKeyFile.json');

beforeEach(function() {
var storage = bucket.storage;
storage.makeAuthorizedRequest_.getCredentials = function(callback) {
callback(null, credentials);
};
});

it('should create a signed policy', function(done) {
file.getSignedPolicy({
expiration: Math.round(Date.now() / 1000) + 5
}, function(err, signedPolicy) {
assert.ifError(err);
assert.equal(typeof signedPolicy.string, 'string');
assert.equal(typeof signedPolicy.base64, 'string');
assert.equal(typeof signedPolicy.signature, 'string');
done();
});
});

it('should add key equality condition', function(done) {
file.getSignedPolicy({
expiration: Math.round(Date.now() / 1000) + 5
}, function(err, signedPolicy) {
var conditionString = '[\"eq\",\"$key\",\"'+file.name+'\"]';
assert.ifError(err);
assert(signedPolicy.string.indexOf(conditionString) > -1);
done();
});
});

it('should add ACL condtion', function(done) {
file.getSignedPolicy({
expiration: Math.round(Date.now() / 1000) + 5,
acl: '<acl>'
}, function(err, signedPolicy) {
var conditionString = '{\"acl\":\"<acl>\"}';
assert.ifError(err);
assert(signedPolicy.string.indexOf(conditionString) > -1);
done();
});
});

describe('expiration', function() {
it('should ISO encode expiration', function(done) {
var expiration = Math.round(Date.now() / 1000) + 5;
var expireDate = new Date(expiration);
file.getSignedPolicy({
expiration: expiration
}, function(err, signedPolicy) {
assert.ifError(err);
assert(signedPolicy.string.indexOf(expireDate.toISOString()) > -1);
done();
});
});

it('should throw if a date from the past is given', function() {
var expirationTimestamp = Math.floor(Date.now() / 1000) - 1;
assert.throws(function() {
file.getSignedPolicy({
expiration: expirationTimestamp
}, function() {});
}, /cannot be in the past/);
});
});

describe('equality condition', function() {
it('should add equality condition', function(done) {
var expiration = Math.round(Date.now() / 1000) + 5;
file.getSignedPolicy({
expiration: expiration,
equals: [['$<field>', '<value>']]
}, function(err, signedPolicy) {
var conditionString = '[\"eq\",\"$<field>\",\"<value>\"]';
assert.ifError(err);
assert(signedPolicy.string.indexOf(conditionString) > -1);
done();
});
});

This comment was marked as spam.

it('should throw if equal condition is not an array', function() {
var expiration = Math.round(Date.now() / 1000) + 5;
assert.throws(function() {
file.getSignedPolicy({
expiration: expiration,
equals: [{}]
}, function() {});
}, /Equals condition must be an array/);
});
it('should throw if equal condition length is not 2', function() {
var expiration = Math.round(Date.now() / 1000) + 5;
assert.throws(function() {
file.getSignedPolicy({
expiration: expiration,
equals: [['1', '2', '3']]
}, function() {});
}, /Equals condition must be an array of 2 elements/);
});
});

describe('prefix conditions', function() {
it('should add prefix condition', function(done) {
var expiration = Math.round(Date.now() / 1000) + 5;
file.getSignedPolicy({
expiration: expiration,
startsWith: [['$<field>', '<value>']]
}, function(err, signedPolicy) {
var conditionString = '[\"starts-with\",\"$<field>\",\"<value>\"]';
assert.ifError(err);
assert(signedPolicy.string.indexOf(conditionString) > -1);
done();
});
});
it('should throw if prexif condition is not an array', function() {
var expiration = Math.round(Date.now() / 1000) + 5;
assert.throws(function() {
file.getSignedPolicy({
expiration: expiration,
startsWith: [{}]
}, function() {});
}, /StartsWith condition must be an array/);
});
it('should throw if prefix condition length is not 2', function() {
var expiration = Math.round(Date.now() / 1000) + 5;
assert.throws(function() {
file.getSignedPolicy({
expiration: expiration,
startsWith: [['1', '2', '3']]
}, function() {});
}, /StartsWith condition must be an array of 2 elements/);
});
});

describe('content length', function() {
it('should add content length condition', function(done) {
var expiration = Math.round(Date.now() / 1000) + 5;
file.getSignedPolicy({
expiration: expiration,
contentLengthRange: {min: 0, max: 1}
}, function(err, signedPolicy) {
var conditionString = '[\"content-length-range\",0,1]';
assert.ifError(err);
assert(signedPolicy.string.indexOf(conditionString) > -1);
done();
});
});
it('should throw if content length has no min', function() {
var expiration = Math.round(Date.now() / 1000) + 5;
assert.throws(function() {
file.getSignedPolicy({
expiration: expiration,
contentLengthRange: [{max: 1}]
}, function() {});
}, /ContentLengthRange must have min and max fields/);
});
it('should throw if content length has no max', function() {
var expiration = Math.round(Date.now() / 1000) + 5;
assert.throws(function() {
file.getSignedPolicy({
expiration: expiration,
contentLengthRange: [{min: 0}]
}, function() {});
}, /ContentLengthRange must have min and max fields/);
});
});
});

describe('setMetadata', function() {
var metadata = { fake: 'metadata' };

Expand Down