diff --git a/CHANGELOG.md b/CHANGELOG.md index f567ecb..335d0fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,14 @@ # Changelog +## 2.5.0 + +- Feature #56: add support for X.509 client certificate based authentication + ## 2.4.1 - Fixed #296: multipart requests are not serialized on browser platform +- Fixed #301: data is not decoded correctly when responseType is "json" (thanks antikalk) +- Fixed #300: FormData object containing null or undefined value is not serialized correctly ## 2.4.0 diff --git a/README.md b/README.md index 95980c0..4135de8 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ This is a fork of [Wymsee's Cordova-HTTP plugin](https://github.com/wymsee/cordo - SSL / TLS Pinning - CORS restrictions do not apply + - X.509 client certificate based authentication - Handling of HTTP code 401 - read more at [Issue CB-2415](https://issues.apache.org/jira/browse/CB-2415) ## Updates @@ -114,7 +115,7 @@ This defaults to `urlencoded`. You can also override the default content type he :warning: `multipart` depends on several Web API standards which need to be supported in your web view. Check out https://github.com/silkimen/cordova-plugin-advanced-http/wiki/Web-APIs-required-for-Multipart-requests for more info. ### setRequestTimeout -Set how long to wait for a request to respond, in seconds. +Set the "read" timeout in seconds. This is the timeout interval to use when waiting for additional data. ```js cordova.plugin.http.setRequestTimeout(5.0); @@ -186,6 +187,29 @@ cordova.plugin.http.setServerTrustMode('nocheck', function() { }); ``` +### setClientAuthMode +Configure X.509 client certificate authentication. Takes mode and options. `mode` being one of following values: + +* `none`: disable client certificate authentication +* `systemstore` (only on Android): use client certificate installed in the Android system store; user will be presented with a list of all installed certificates +* `buffer`: use given client certificate; you will need to provide an options object: + * `rawPkcs`: ArrayBuffer containing raw PKCS12 container with client certificate and private key + * `pkcsPassword`: password of the PKCS container + +```js + // enable client auth using PKCS12 container given in ArrayBuffer `myPkcs12ArrayBuffer` + cordova.plugin.http.setClientAuthMode('buffer', { + rawPkcs: myPkcs12ArrayBuffer, + pkcsPassword: 'mySecretPassword' + }, success, fail); + + // enable client auth using certificate in system store (only on Android) + cordova.plugin.http.setClientAuthMode('systemstore', {}, success, fail); + + // disable client auth + cordova.plugin.http.setClientAuthMode('none', {}, success, fail); +``` + ### disableRedirect (deprecated) This function was deprecated in 2.0.9. Use ["setFollowRedirect"](#setFollowRedirect) instead. diff --git a/package-lock.json b/package-lock.json index 2302d82..4e0beed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "cordova-plugin-advanced-http", - "version": "2.4.0", + "version": "2.4.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 8548a50..c29ce3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cordova-plugin-advanced-http", - "version": "2.4.1", + "version": "2.5.0", "description": "Cordova / Phonegap plugin for communicating with HTTP servers using SSL pinning", "scripts": { "updatecert": "node ./scripts/update-e2e-server-cert.js && node ./scripts/update-e2e-client-cert.js", @@ -69,4 +69,4 @@ "wd": "1.4.1", "xml2js": "0.4.19" } -} +} \ No newline at end of file diff --git a/plugin.xml b/plugin.xml index 0c08d4d..1918419 100644 --- a/plugin.xml +++ b/plugin.xml @@ -1,5 +1,5 @@ - + Advanced HTTP plugin Cordova / Phonegap plugin for communicating with HTTP servers using SSL pinning diff --git a/scripts/test-app.sh b/scripts/test-app.sh index 245376b..4482e5e 100755 --- a/scripts/test-app.sh +++ b/scripts/test-app.sh @@ -3,8 +3,8 @@ set -e ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )"; cd ..; pwd )" -if [ $CI == "true" ] && ([ -z $SAUCE_USERNAME ] || [ -z $SAUCE_ACCESS_KEY ]); then - echo "Skipping CI tests, because Saucelabs credentials are not set."; +if [ $CI == "true" ] && ([ -z $SAUCE_USERNAME ] || [ -z $SAUCE_ACCESS_KEY ]) && ([ -z $BROWSERSTACK_USERNAME ] || [ -z $BROWSERSTACK_ACCESS_KEY ]); then + echo "Skipping CI tests, because Saucelabs and BrowserStack credentials are not set."; exit 0; fi diff --git a/src/ios/CordovaHttpPlugin.h b/src/ios/CordovaHttpPlugin.h index 3b35e17..0e5d867 100644 --- a/src/ios/CordovaHttpPlugin.h +++ b/src/ios/CordovaHttpPlugin.h @@ -5,6 +5,7 @@ @interface CordovaHttpPlugin : CDVPlugin - (void)setServerTrustMode:(CDVInvokedUrlCommand*)command; +- (void)setClientAuthMode:(CDVInvokedUrlCommand*)command; - (void)post:(CDVInvokedUrlCommand*)command; - (void)put:(CDVInvokedUrlCommand*)command; - (void)patch:(CDVInvokedUrlCommand*)command; diff --git a/src/ios/CordovaHttpPlugin.m b/src/ios/CordovaHttpPlugin.m index 2f8c750..d7f8fcc 100644 --- a/src/ios/CordovaHttpPlugin.m +++ b/src/ios/CordovaHttpPlugin.m @@ -21,6 +21,7 @@ @implementation CordovaHttpPlugin { AFSecurityPolicy *securityPolicy; + NSURLCredential *x509Credential; } - (void)pluginInitialize { @@ -39,6 +40,33 @@ } } +- (void)setupAuthChallengeBlock:(AFHTTPSessionManager*)manager { + [manager setSessionDidReceiveAuthenticationChallengeBlock:^NSURLSessionAuthChallengeDisposition( + NSURLSession * _Nonnull session, + NSURLAuthenticationChallenge * _Nonnull challenge, + NSURLCredential * _Nullable __autoreleasing * _Nullable credential + ) { + if ([challenge.protectionSpace.authenticationMethod isEqualToString: NSURLAuthenticationMethodServerTrust]) { + *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust]; + + if (![self->securityPolicy evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:challenge.protectionSpace.host]) { + return NSURLSessionAuthChallengeRejectProtectionSpace; + } + + if (credential) { + return NSURLSessionAuthChallengeUseCredential; + } + } + + if ([challenge.protectionSpace.authenticationMethod isEqualToString: NSURLAuthenticationMethodClientCertificate] && self->x509Credential) { + *credential = self->x509Credential; + return NSURLSessionAuthChallengeUseCredential; + } + + return NSURLSessionAuthChallengePerformDefaultHandling; + }]; +} + - (void)setRequestHeaders:(NSDictionary*)headers forManager:(AFHTTPSessionManager*)manager { [headers enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { [manager.requestSerializer setValue:obj forHTTPHeaderField:key]; @@ -147,7 +175,6 @@ - (void)executeRequestWithoutData:(CDVInvokedUrlCommand*)command withMethod:(NSString*) method { AFHTTPSessionManager *manager = [AFHTTPSessionManager manager]; - manager.securityPolicy = securityPolicy; NSString *url = [command.arguments objectAtIndex:0]; NSDictionary *headers = [command.arguments objectAtIndex:1]; @@ -156,6 +183,7 @@ NSString *responseType = [command.arguments objectAtIndex:4]; [self setRequestSerializer: @"default" forManager: manager]; + [self setupAuthChallengeBlock: manager]; [self setRequestHeaders: headers forManager: manager]; [self setTimeout:timeoutInSeconds forManager:manager]; [self setRedirect:followRedirect forManager:manager]; @@ -199,7 +227,6 @@ - (void)executeRequestWithData:(CDVInvokedUrlCommand*)command withMethod:(NSString*)method { AFHTTPSessionManager *manager = [AFHTTPSessionManager manager]; - manager.securityPolicy = securityPolicy; NSString *url = [command.arguments objectAtIndex:0]; NSDictionary *data = [command.arguments objectAtIndex:1]; @@ -210,6 +237,7 @@ NSString *responseType = [command.arguments objectAtIndex:6]; [self setRequestSerializer: serializerName forManager: manager]; + [self setupAuthChallengeBlock: manager]; [self setRequestHeaders: headers forManager: manager]; [self setTimeout:timeoutInSeconds forManager:manager]; [self setRedirect:followRedirect forManager:manager]; @@ -302,6 +330,51 @@ [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } +- (void)setClientAuthMode:(CDVInvokedUrlCommand*)command { + CDVPluginResult* pluginResult; + NSString *mode = [command.arguments objectAtIndex:0]; + + if ([mode isEqualToString:@"none"]) { + x509Credential = nil; + pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK]; + } + + if ([mode isEqualToString:@"systemstore"]) { + NSString *alias = [command.arguments objectAtIndex:1]; + + // TODO + + pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:@"mode 'systemstore' is not supported on iOS"]; + } + + if ([mode isEqualToString:@"buffer"]) { + CFDataRef container = (__bridge CFDataRef) [command.arguments objectAtIndex:2]; + CFStringRef password = (__bridge CFStringRef) [command.arguments objectAtIndex:3]; + + const void *keys[] = { kSecImportExportPassphrase }; + const void *values[] = { password }; + + CFDictionaryRef options = CFDictionaryCreate(NULL, keys, values, 1, NULL, NULL); + CFArrayRef items; + OSStatus securityError = SecPKCS12Import(container, options, &items); + CFRelease(options); + + if (securityError != noErr) { + pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR]; + } else { + CFDictionaryRef identityDict = CFArrayGetValueAtIndex(items, 0); + SecIdentityRef identity = (SecIdentityRef)CFDictionaryGetValue(identityDict, kSecImportItemIdentity); + + self->x509Credential = [NSURLCredential credentialWithIdentity:identity certificates: nil persistence:NSURLCredentialPersistenceForSession]; + CFRelease(items); + + pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK]; + } + } + + [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; +} + - (void)post:(CDVInvokedUrlCommand*)command { [self executeRequestWithData: command withMethod:@"POST"]; } @@ -332,7 +405,6 @@ - (void)uploadFiles:(CDVInvokedUrlCommand*)command { AFHTTPSessionManager *manager = [AFHTTPSessionManager manager]; - manager.securityPolicy = securityPolicy; NSString *url = [command.arguments objectAtIndex:0]; NSDictionary *headers = [command.arguments objectAtIndex:1]; @@ -343,6 +415,7 @@ NSString *responseType = [command.arguments objectAtIndex:6]; [self setRequestHeaders: headers forManager: manager]; + [self setupAuthChallengeBlock: manager]; [self setTimeout:timeoutInSeconds forManager:manager]; [self setRedirect:followRedirect forManager:manager]; [self setResponseSerializer:responseType forManager:manager]; @@ -392,7 +465,6 @@ - (void)downloadFile:(CDVInvokedUrlCommand*)command { AFHTTPSessionManager *manager = [AFHTTPSessionManager manager]; - manager.securityPolicy = securityPolicy; manager.responseSerializer = [AFHTTPResponseSerializer serializer]; NSString *url = [command.arguments objectAtIndex:0]; @@ -402,6 +474,7 @@ bool followRedirect = [[command.arguments objectAtIndex:4] boolValue]; [self setRequestHeaders: headers forManager: manager]; + [self setupAuthChallengeBlock: manager]; [self setTimeout:timeoutInSeconds forManager:manager]; [self setRedirect:followRedirect forManager:manager]; diff --git a/test/e2e-specs.js b/test/e2e-specs.js index ec1349d..b3691c3 100644 --- a/test/e2e-specs.js +++ b/test/e2e-specs.js @@ -3,7 +3,7 @@ const hooks = { cordova.plugin.http.clearCookies(); helpers.enableFollowingRedirect(function() { - // server trust mode is not supported on brpwser platform + // server trust mode is not supported on browser platform if (cordova.platformId === 'browser') { return resolve(); } @@ -787,7 +787,7 @@ const tests = [ }, { description: 'should decode error body even if response type is "arraybuffer"', - expected: 'rejected: {"status": 418, ...', + expected: 'rejected: {"status":418, ...', func: function (resolve, reject) { var url = 'https://httpbin.org/status/418'; var options = { method: 'get', responseType: 'arraybuffer' }; @@ -801,7 +801,7 @@ const tests = [ }, { description: 'should serialize FormData instance correctly when it contains string value', - expected: 'resolved: {"status": 200, ...', + expected: 'resolved: {"status":200, ...', before: helpers.setMultipartSerializer, func: function (resolve, reject) { var ponyfills = cordova.plugin.http.ponyfills; @@ -820,7 +820,7 @@ const tests = [ }, { description: 'should serialize FormData instance correctly when it contains blob value', - expected: 'resolved: {"status": 200, ...', + expected: 'resolved: {"status":200, ...', before: helpers.setMultipartSerializer, func: function (resolve, reject) { var ponyfills = cordova.plugin.http.ponyfills; @@ -901,18 +901,72 @@ const tests = [ should.equal(null, result.data.data); } }, + { + description: 'should decode JSON data correctly when response type is "json" #301', + expected: 'resolved: {"status":200,"data":{"slideshow": ... ', + func: function (resolve, reject) { + var url = 'https://httpbin.org/json'; + var options = { method: 'get', responseType: 'json' }; + cordova.plugin.http.sendRequest(url, options, resolve, reject); + }, + validationFunc: function (driver, result) { + result.type.should.be.equal('resolved'); + result.data.status.should.be.equal(200); + result.data.data.should.be.an('object'); + result.data.data.slideshow.should.be.eql({ + author: 'Yours Truly', + date: 'date of publication', + slides: [ + { + title: 'Wake up to WonderWidgets!', + type: 'all' + }, + { + items: [ + 'Why WonderWidgets are great', + 'Who buys WonderWidgets' + ], + title: 'Overview', + type: 'all' + } + ], + title: 'Sample Slide Show' + }); + } + }, + { + description: 'should serialize FormData instance correctly when it contains null or undefined value #300', + expected: 'resolved: {"status":200, ...', + before: helpers.setMultipartSerializer, + func: function (resolve, reject) { + var ponyfills = cordova.plugin.http.ponyfills; + var formData = new ponyfills.FormData(); + formData.append('myNullValue', null); + formData.append('myUndefinedValue', undefined); - // TODO: not ready yet - // { - // description: 'should authenticate correctly when client cert auth is configured with a PKCS12 container', - // expected: 'resolved: {"status": 200, ...', - // before: helpers.setBufferClientAuthMode, - // func: function (resolve, reject) { cordova.plugin.http.get('https://client.badssl.com/', {}, {}, resolve, reject); }, - // validationFunc: function (driver, result) { - // result.type.should.be.equal('resolved'); - // result.data.data.should.include('TLS handshake'); - // } - // } + var url = 'https://httpbin.org/anything'; + var options = { method: 'post', data: formData }; + cordova.plugin.http.sendRequest(url, options, resolve, reject); + }, + validationFunc: function (driver, result) { + helpers.checkResult(result, 'resolved'); + result.data.status.should.be.equal(200); + JSON.parse(result.data.data).form.should.be.eql({ + myNullValue: 'null', + myUndefinedValue: 'undefined' + }); + } + }, + { + description: 'should authenticate correctly when client cert auth is configured with a PKCS12 container', + expected: 'resolved: {"status": 200, ...', + before: helpers.setBufferClientAuthMode, + func: function (resolve, reject) { cordova.plugin.http.get('https://client.badssl.com/', {}, {}, resolve, reject); }, + validationFunc: function (driver, result) { + result.type.should.be.equal('resolved'); + result.data.data.should.include('TLS handshake'); + } + } ]; if (typeof module !== 'undefined' && module.exports) { diff --git a/test/e2e-tooling/caps.js b/test/e2e-tooling/caps.js index 0548853..fa92769 100644 --- a/test/e2e-tooling/caps.js +++ b/test/e2e-tooling/caps.js @@ -67,8 +67,8 @@ const configs = { app: 'HttpTestAppAndroid' }, browserstackAndroidDevice: { - device: 'Google Nexus 9', - os_version: '5.1', + device: 'Google Nexus 6', + os_version: '5.0', project: 'HTTP Test App', autoWebview: true, app: 'HttpTestAppAndroid' diff --git a/www/ponyfills.js b/www/ponyfills.js index 67b9bbc..e49e6d7 100644 --- a/www/ponyfills.js +++ b/www/ponyfills.js @@ -18,7 +18,7 @@ module.exports = function init(global) { value.lastModifiedDate = new Date(); value.name = filename || ''; } else { - value = value.toString ? value.toString() : value; + value = String(value); } this.__items.push([ name, value ]);