CB-5988 Allow exec() only from file: or start-up URL's domain

Uses prompt() to validate the origin of the calling JS.
This change also simplifies the start-up logic by explicitly disabling
the bridge during page transitions and explictly enabling it when the
JS asks for the bridgeSecret.

We now wait to fire onNativeReady in JS until the bridge is initialized.
It is therefore safe to delete the queue-clear/new exec race condition
code that was in PluginManager.
This commit is contained in:
Andrew Grieve 2014-07-03 21:58:35 -04:00
parent 445ddd89fb
commit aab47bd453
5 changed files with 132 additions and 142 deletions

View File

@ -28,6 +28,7 @@ import android.app.AlertDialog;
import android.content.DialogInterface; import android.content.DialogInterface;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.util.Log;
import android.view.Gravity; import android.view.Gravity;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.View; import android.view.View;
@ -201,64 +202,75 @@ public class CordovaChromeClient extends WebChromeClient {
* Since we are hacking prompts for our own purposes, we should not be using them for * Since we are hacking prompts for our own purposes, we should not be using them for
* this purpose, perhaps we should hack console.log to do this instead! * this purpose, perhaps we should hack console.log to do this instead!
* *
* @param view
* @param url
* @param message
* @param defaultValue
* @param result
* @see Other implementation in the Dialogs plugin. * @see Other implementation in the Dialogs plugin.
*/ */
@Override @Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) { public boolean onJsPrompt(WebView view, String origin, String message, String defaultValue, JsPromptResult result) {
// Unlike the @JavascriptInterface bridge, this method is always called on the UI thread.
// Security check to make sure any requests are coming from the page initially if (defaultValue != null && defaultValue.length() > 3 && defaultValue.startsWith("gap:")) {
// loaded in webview and not another loaded in an iframe.
boolean reqOk = false;
if (url.startsWith("file://") || Config.isUrlWhiteListed(url)) {
reqOk = true;
}
// Calling PluginManager.exec() to call a native service using
// prompt(this.stringify(args), "gap:"+this.stringify([service, action, callbackId, true]));
if (reqOk && defaultValue != null && defaultValue.length() > 3 && defaultValue.substring(0, 4).equals("gap:")) {
JSONArray array; JSONArray array;
try { try {
array = new JSONArray(defaultValue.substring(4)); array = new JSONArray(defaultValue.substring(4));
String service = array.getString(0); int bridgeSecret = array.getInt(0);
String action = array.getString(1); String service = array.getString(1);
String callbackId = array.getString(2); String action = array.getString(2);
String r = this.appView.exposedJsApi.exec(service, action, callbackId, message); String callbackId = array.getString(3);
String r = appView.exposedJsApi.exec(bridgeSecret, service, action, callbackId, message);
result.confirm(r == null ? "" : r); result.confirm(r == null ? "" : r);
} catch (JSONException e) { } catch (JSONException e) {
e.printStackTrace(); e.printStackTrace();
return false; result.cancel();
} catch (IllegalAccessException e) {
e.printStackTrace();
result.cancel();
} }
} }
// Sets the native->JS bridge mode. // Sets the native->JS bridge mode.
else if (reqOk && defaultValue != null && defaultValue.equals("gap_bridge_mode:")) { else if (defaultValue != null && defaultValue.startsWith("gap_bridge_mode:")) {
try { try {
this.appView.exposedJsApi.setNativeToJsBridgeMode(Integer.parseInt(message)); int bridgeSecret = Integer.parseInt(defaultValue.substring(16));
result.confirm(""); appView.exposedJsApi.setNativeToJsBridgeMode(bridgeSecret, Integer.parseInt(message));
result.cancel();
} catch (NumberFormatException e){ } catch (NumberFormatException e){
result.confirm("");
e.printStackTrace(); e.printStackTrace();
result.cancel();
} catch (IllegalAccessException e) {
e.printStackTrace();
result.cancel();
} }
} }
// Polling for JavaScript messages // Polling for JavaScript messages
else if (reqOk && defaultValue != null && defaultValue.equals("gap_poll:")) { else if (defaultValue != null && defaultValue.startsWith("gap_poll:")) {
String r = this.appView.exposedJsApi.retrieveJsMessages("1".equals(message)); int bridgeSecret = Integer.parseInt(defaultValue.substring(9));
try {
String r = appView.exposedJsApi.retrieveJsMessages(bridgeSecret, "1".equals(message));
result.confirm(r == null ? "" : r); result.confirm(r == null ? "" : r);
} catch (IllegalAccessException e) {
e.printStackTrace();
result.cancel();
}
} }
// Do NO-OP so older code doesn't display dialog else if (defaultValue != null && defaultValue.startsWith("gap_init:")) {
else if (defaultValue != null && defaultValue.equals("gap_init:")) { String startUrl = Config.getStartUrl();
result.confirm("OK"); // Protect against random iframes being able to talk through the bridge.
// Trust only file URLs and the start URL's domain.
// The extra origin.startsWith("http") is to protect against iframes with data: having "" as origin.
if (origin.startsWith("file:") || (origin.startsWith("http") && startUrl.startsWith(origin))) {
// Enable the bridge
int bridgeMode = Integer.parseInt(defaultValue.substring(9));
appView.jsMessageQueue.setBridgeMode(bridgeMode);
// Tell JS the bridge secret.
int secret = appView.exposedJsApi.generateBridgeSecret();
result.confirm(""+secret);
} else {
Log.e(LOG_TAG, "gap_init called from restricted origin: " + origin);
result.cancel();
} }
} else {
// Show dialog // Returning false would also show a dialog, but the default one shows the origin (ugly).
else {
final JsPromptResult res = result; final JsPromptResult res = result;
AlertDialog.Builder dlg = new AlertDialog.Builder(this.cordova.getActivity()); AlertDialog.Builder dlg = new AlertDialog.Builder(this.cordova.getActivity());
dlg.setMessage(message); dlg.setMessage(message);

View File

@ -18,7 +18,6 @@
*/ */
package org.apache.cordova; package org.apache.cordova;
import java.io.ByteArrayInputStream;
import java.util.Hashtable; import java.util.Hashtable;
import org.apache.cordova.CordovaInterface; import org.apache.cordova.CordovaInterface;
@ -28,18 +27,15 @@ import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
import android.annotation.TargetApi; import android.annotation.TargetApi;
import android.content.Intent;
import android.content.pm.ApplicationInfo; import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.PackageManager.NameNotFoundException;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.net.Uri;
import android.net.http.SslError; import android.net.http.SslError;
import android.util.Log; import android.util.Log;
import android.view.View; import android.view.View;
import android.webkit.HttpAuthHandler; import android.webkit.HttpAuthHandler;
import android.webkit.SslErrorHandler; import android.webkit.SslErrorHandler;
import android.webkit.WebResourceResponse;
import android.webkit.WebView; import android.webkit.WebView;
import android.webkit.WebViewClient; import android.webkit.WebViewClient;
@ -170,6 +166,7 @@ public class CordovaWebViewClient extends WebViewClient {
LOG.d(TAG, "onPageStarted(" + url + ")"); LOG.d(TAG, "onPageStarted(" + url + ")");
// Flush stale messages. // Flush stale messages.
this.appView.jsMessageQueue.reset(); this.appView.jsMessageQueue.reset();
this.appView.exposedJsApi.clearBridgeSecret();
// Broadcast message that page has loaded // Broadcast message that page has loaded
this.appView.postMessage("onPageStarted", url); this.appView.postMessage("onPageStarted", url);

View File

@ -19,6 +19,7 @@
package org.apache.cordova; package org.apache.cordova;
import android.webkit.JavascriptInterface; import android.webkit.JavascriptInterface;
import org.apache.cordova.PluginManager; import org.apache.cordova.PluginManager;
import org.json.JSONException; import org.json.JSONException;
@ -31,6 +32,7 @@ import org.json.JSONException;
private PluginManager pluginManager; private PluginManager pluginManager;
private NativeToJsMessageQueue jsMessageQueue; private NativeToJsMessageQueue jsMessageQueue;
private volatile int bridgeSecret = -1; // written by UI thread, read by JS thread.
public ExposedJsApi(PluginManager pluginManager, NativeToJsMessageQueue jsMessageQueue) { public ExposedJsApi(PluginManager pluginManager, NativeToJsMessageQueue jsMessageQueue) {
this.pluginManager = pluginManager; this.pluginManager = pluginManager;
@ -38,7 +40,8 @@ import org.json.JSONException;
} }
@JavascriptInterface @JavascriptInterface
public String exec(String service, String action, String callbackId, String arguments) throws JSONException { public String exec(int bridgeSecret, String service, String action, String callbackId, String arguments) throws JSONException, IllegalAccessException {
verifySecret(bridgeSecret);
// If the arguments weren't received, send a message back to JS. It will switch bridge modes and try again. See CB-2666. // If the arguments weren't received, send a message back to JS. It will switch bridge modes and try again. See CB-2666.
// We send a message meant specifically for this case. It starts with "@" so no other message can be encoded into the same string. // We send a message meant specifically for this case. It starts with "@" so no other message can be encoded into the same string.
if (arguments == null) { if (arguments == null) {
@ -65,12 +68,31 @@ import org.json.JSONException;
} }
@JavascriptInterface @JavascriptInterface
public void setNativeToJsBridgeMode(int value) { public void setNativeToJsBridgeMode(int bridgeSecret, int value) throws IllegalAccessException {
verifySecret(bridgeSecret);
jsMessageQueue.setBridgeMode(value); jsMessageQueue.setBridgeMode(value);
} }
@JavascriptInterface @JavascriptInterface
public String retrieveJsMessages(boolean fromOnlineEvent) { public String retrieveJsMessages(int bridgeSecret, boolean fromOnlineEvent) throws IllegalAccessException {
verifySecret(bridgeSecret);
return jsMessageQueue.popAndEncode(fromOnlineEvent); return jsMessageQueue.popAndEncode(fromOnlineEvent);
} }
private void verifySecret(int value) throws IllegalAccessException {
if (bridgeSecret < 0 || value != bridgeSecret) {
throw new IllegalAccessException();
}
}
/** Called on page transitions */
void clearBridgeSecret() {
bridgeSecret = -1;
}
/** Called by cordova.js to initialize the bridge. */
int generateBridgeSecret() {
bridgeSecret = (int)(Math.random() * Integer.MAX_VALUE);
return bridgeSecret;
}
} }

View File

@ -35,9 +35,6 @@ import android.webkit.WebView;
public class NativeToJsMessageQueue { public class NativeToJsMessageQueue {
private static final String LOG_TAG = "JsMessageQueue"; private static final String LOG_TAG = "JsMessageQueue";
// This must match the default value in cordova-js/lib/android/exec.js
private static final int DEFAULT_BRIDGE_MODE = 2;
// Set this to true to force plugin results to be encoding as // Set this to true to force plugin results to be encoding as
// JS instead of the custom format (useful for benchmarking). // JS instead of the custom format (useful for benchmarking).
private static final boolean FORCE_ENCODE_USING_EVAL = false; private static final boolean FORCE_ENCODE_USING_EVAL = false;
@ -55,11 +52,6 @@ public class NativeToJsMessageQueue {
// to allow it to break up messages. // to allow it to break up messages.
private static int MAX_PAYLOAD_SIZE = 50 * 1024 * 10240; private static int MAX_PAYLOAD_SIZE = 50 * 1024 * 10240;
/**
* The index into registeredListeners to treat as active.
*/
private int activeListenerIndex;
/** /**
* When true, the active listener is not fired upon enqueue. When set to false, * When true, the active listener is not fired upon enqueue. When set to false,
* the active listener will be fired if the queue is non-empty. * the active listener will be fired if the queue is non-empty.
@ -76,6 +68,13 @@ public class NativeToJsMessageQueue {
*/ */
private final BridgeMode[] registeredListeners; private final BridgeMode[] registeredListeners;
/**
* When null, the bridge is disabled. This occurs during page transitions.
* When disabled, all callbacks are dropped since they are assumed to be
* relevant to the previous page.
*/
private BridgeMode activeBridgeMode;
private final CordovaInterface cordova; private final CordovaInterface cordova;
private final CordovaWebView webView; private final CordovaWebView webView;
@ -94,17 +93,19 @@ public class NativeToJsMessageQueue {
* Changes the bridge mode. * Changes the bridge mode.
*/ */
public void setBridgeMode(int value) { public void setBridgeMode(int value) {
if (value < 0 || value >= registeredListeners.length) { if (value < -1 || value >= registeredListeners.length) {
Log.d(LOG_TAG, "Invalid NativeToJsBridgeMode: " + value); Log.d(LOG_TAG, "Invalid NativeToJsBridgeMode: " + value);
} else { } else {
if (value != activeListenerIndex) { BridgeMode newMode = value < 0 ? null : registeredListeners[value];
Log.d(LOG_TAG, "Set native->JS mode to " + value); if (newMode != activeBridgeMode) {
Log.d(LOG_TAG, "Set native->JS mode to " + (newMode == null ? "null" : newMode.getClass().getSimpleName()));
synchronized (this) { synchronized (this) {
activeListenerIndex = value; activeBridgeMode = newMode;
BridgeMode activeListener = registeredListeners[value]; if (newMode != null) {
activeListener.reset(); newMode.reset();
if (!paused && !queue.isEmpty()) { if (!paused && !queue.isEmpty()) {
activeListener.onNativeToJsMessageAvailable(); newMode.onNativeToJsMessageAvailable();
}
} }
} }
} }
@ -117,8 +118,7 @@ public class NativeToJsMessageQueue {
public void reset() { public void reset() {
synchronized (this) { synchronized (this) {
queue.clear(); queue.clear();
setBridgeMode(DEFAULT_BRIDGE_MODE); setBridgeMode(-1);
registeredListeners[activeListenerIndex].reset();
} }
} }
@ -142,7 +142,10 @@ public class NativeToJsMessageQueue {
*/ */
public String popAndEncode(boolean fromOnlineEvent) { public String popAndEncode(boolean fromOnlineEvent) {
synchronized (this) { synchronized (this) {
registeredListeners[activeListenerIndex].notifyOfFlush(fromOnlineEvent); if (activeBridgeMode == null) {
return null;
}
activeBridgeMode.notifyOfFlush(fromOnlineEvent);
if (queue.isEmpty()) { if (queue.isEmpty()) {
return null; return null;
} }
@ -250,9 +253,13 @@ public class NativeToJsMessageQueue {
private void enqueueMessage(JsMessage message) { private void enqueueMessage(JsMessage message) {
synchronized (this) { synchronized (this) {
if (activeBridgeMode == null) {
Log.d(LOG_TAG, "Dropping Native->JS message due to disabled bridge");
return;
}
queue.add(message); queue.add(message);
if (!paused) { if (!paused) {
registeredListeners[activeListenerIndex].onNativeToJsMessageAvailable(); activeBridgeMode.onNativeToJsMessageAvailable();
} }
} }
} }
@ -266,17 +273,13 @@ public class NativeToJsMessageQueue {
paused = value; paused = value;
if (!value) { if (!value) {
synchronized (this) { synchronized (this) {
if (!queue.isEmpty()) { if (!queue.isEmpty() && activeBridgeMode != null) {
registeredListeners[activeListenerIndex].onNativeToJsMessageAvailable(); activeBridgeMode.onNativeToJsMessageAvailable();
} }
} }
} }
} }
public boolean getPaused() {
return paused;
}
private abstract class BridgeMode { private abstract class BridgeMode {
abstract void onNativeToJsMessageAvailable(); abstract void onNativeToJsMessageAvailable();
void notifyOfFlush(boolean fromOnlineEvent) {} void notifyOfFlush(boolean fromOnlineEvent) {}

View File

@ -23,9 +23,7 @@ import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.cordova.CordovaArgs;
import org.apache.cordova.CordovaWebView; import org.apache.cordova.CordovaWebView;
import org.apache.cordova.CallbackContext; import org.apache.cordova.CallbackContext;
import org.apache.cordova.CordovaInterface; import org.apache.cordova.CordovaInterface;
@ -65,8 +63,6 @@ public class PluginManager {
// Using <url-filter> is deprecated. // Using <url-filter> is deprecated.
protected HashMap<String, List<String>> urlMap = new HashMap<String, List<String>>(); protected HashMap<String, List<String>> urlMap = new HashMap<String, List<String>>();
private AtomicInteger numPendingUiExecs;
/** /**
* Constructor. * Constructor.
* *
@ -77,7 +73,6 @@ public class PluginManager {
this.ctx = ctx; this.ctx = ctx;
this.app = app; this.app = app;
this.firstRun = true; this.firstRun = true;
this.numPendingUiExecs = new AtomicInteger(0);
} }
/** /**
@ -99,9 +94,6 @@ public class PluginManager {
this.clearPluginObjects(); this.clearPluginObjects();
} }
// Insert PluginManager service
this.addService(new PluginEntry("PluginManager", new PluginManagerService()));
// Start up all plugins that have onload specified // Start up all plugins that have onload specified
this.startupPlugins(); this.startupPlugins();
} }
@ -216,20 +208,6 @@ public class PluginManager {
* plugin execute method. * plugin execute method.
*/ */
public void exec(final String service, final String action, final String callbackId, final String rawArgs) { public void exec(final String service, final String action, final String callbackId, final String rawArgs) {
if (numPendingUiExecs.get() > 0) {
numPendingUiExecs.getAndIncrement();
this.ctx.getActivity().runOnUiThread(new Runnable() {
public void run() {
execHelper(service, action, callbackId, rawArgs);
numPendingUiExecs.getAndDecrement();
}
});
} else {
execHelper(service, action, callbackId, rawArgs);
}
}
private void execHelper(final String service, final String action, final String callbackId, final String rawArgs) {
CordovaPlugin plugin = getPlugin(service); CordovaPlugin plugin = getPlugin(service);
if (plugin == null) { if (plugin == null) {
Log.d(TAG, "exec() call to unknown plugin: " + service); Log.d(TAG, "exec() call to unknown plugin: " + service);
@ -437,26 +415,4 @@ public class PluginManager {
} }
return null; return null;
} }
private class PluginManagerService extends CordovaPlugin {
@Override
public boolean execute(String action, CordovaArgs args, final CallbackContext callbackContext) throws JSONException {
if ("startup".equals(action)) {
// The onPageStarted event of CordovaWebViewClient resets the queue of messages to be returned to javascript in response
// to exec calls. Since this event occurs on the UI thread and exec calls happen on the WebCore thread it is possible
// that onPageStarted occurs after exec calls have started happening on a new page, which can cause the message queue
// to be reset between the queuing of a new message and its retrieval by javascript. To avoid this from happening,
// javascript always sends a "startup" exec upon loading a new page which causes all future exec calls to happen on the UI
// thread (and hence after onPageStarted) until there are no more pending exec calls remaining.
numPendingUiExecs.getAndIncrement();
ctx.getActivity().runOnUiThread(new Runnable() {
public void run() {
numPendingUiExecs.getAndDecrement();
}
});
return true;
}
return false;
}
}
} }