内容


创建基于安全性的机器学习前端

编写机器学习前端来识别 Node.js 应用程序中不一致的安全信息

Comments

简介

在本文中,您将学习如何创建一个安全前端来自动学习应用程序输入的正确格式。借助此信息,前端可识别异常输入,然后拦截或触发警报。尽管此方案不是一个完美的解决方案,但它可以大大降低应用程序面临的风险。

演示此技术的示例应用程序是使用 Node.js 编写的,并在 Bluemix® 平台上运行。但是,同样的原理适用于在任何环境中运行的任何 Web 应用程序平台。

构建您的应用程序需要做的准备工作

运行应用程序获取代码

是否想自行创建该应用程序?

为了快速入门,您现在可以将这个预先构建的简单应用程序直接部署到 Bluemix。您可以在这里编辑和重新部署该代码,多少次都行。

异常输入问题

许多程序员天真地认为他们从互联网获取的输入是有效的,至少这些输入看起来像是来自他们自己的应用程序的客户端。因此,他们忽视了进行冒烟测试来确认他们收到的输入是合法的。但是,客户端通常是由用户控制的浏览器。黑客向服务器端发送他们想要的任何输入,而且有可能破坏应用程序,这并不困难。

理想的解决方案是让程序员编写更好的代码。但是,因为很难更改人类行为,所以创建一个前端,让它代表您来了解应用程序获取的信息要容易得多。前端在获取其他任何信息时也会发觉该情况并做出相应的反应,比如发送警报,拦截输入等。它不是一个完美的解决方案,因为可能存在误报和漏报,但它可以提高应用程序安全性。

在应用程序之前捕获 Node.js 输入

在本文中,我们修改一个 Node.js 应用程序,添加了一个模块,以便在主应用程序验证输入之前接收输入。这种修改非常简单,因为 Node.js 的 HTTP 服务器包明确支持使用它们所谓的中间件。用于 Node.js 的中间件是接收请求,以某种方式修改请求,然后转发它以供进一步处理的代码。Node.js 中间件是 Node.js 处理输入解析的方式。

有 4 种主要方法可用来向 Web 应用程序提供输入:

  1. 在 URL 的路径中。
  2. 在 URL 的查询中(包含 GET 方法的表单)。
  3. 在采用 URL 编码值形式的请求正文中(包含 POST 方法的表单)。
  4. 在从客户端发送的 JSON 请求中(通常采用包含 POST 或 PUT 方法的 REST 请求)。

如果应用程序使用了 POST 或 JSON,那么它就拥有了解析这些请求的代码。下面的代码段给出了处理所有选项的代码:

// Parse post and put requests as JSON or a POST Form as appropriate. 
app.post("*", bodyParser.json({type:"application/json"})); 
app.put("*", bodyParser.json({type:"application/json"})); 
app.post("*", bodyParser.urlencoded({type:"application/x-www-form-urlencoded", extended: true}));

出于我们的目的,我们希望在这些解析器后捕获请求。在调用它们的代码行的后面,插入以下代码:

var reqLog = [];

// Catch all HTTP requests
app.all("*", function(req, res, next) {
	
	reqLog[reqLog.length] = {
		path: req.originalUrl,
		method: req.method,
		query: req.query,
		body: typeof req.body === "undefined" ? {} : req.body
	};
	
	if (req.query.length > 0)
		reqLog[reqLog.length-1].query = req.query;

	if (typeof req.body !== "undefined")
		reqLog[reqLog.length-1].body = req.body;	

	next();
});


// Show the request log
app.get("/reqLog", function(req, res) {
	res.send(JSON.stringify(reqLog));
	
	reqLog = [];
});

使用示例应用程序,运行一些请求,然后导航到 https://machine-learning-front-end.mybluemix.net/reqLog 来查看结果。如果结果与期望不符,请记住,随着更多读者运行请求并访问该 URL,难免会出现不一致现象。如果结果不一致,请记住,其他读者可能同时在使用该应用程序。另请注意,在每次访问 reqLog 时都会将其清空。最后,您可能想剪切结果并粘贴在一个 JSON 格式化器 上,以便更清楚地了解它。

工作原理是什么?

app.all("*", function(req, res, next) {…}) 调用接收与路径匹配的请求(在本例中是所有请求)。因为我们使用了 .all,所以它涵盖所有方法。

function 参数本身有 3 个参数。常见的两个参数 reqres 有自己的一般含义。但还有一个额外的参数 next,它不常使用。它是一个调用,旨在返回请求供进一步处理。我们正常的 app.getapp.post 调用也可以使用此参数,只是在函数提供了要发回给浏览器的实际响应时不需要使用它。

path 和 method 是 HTTP 请求的必要部分,所以它们始终存在。req.query 也始终存在,因为它由 Express 自动解析,而且也可能是空的。最后一个值 req.body 更加复杂。可以使用 bodyParser.json()bodyParser.urlencoded() 创建最后这个值,但它们仅在指定的时刻调用。在示例应用程序中,仅为一些方法调用它,而且仅在内容类型为 application/jsonapplication/x-www-form-urlencoded 时调用它。

// Parse post and put requests as JSON or a POST Form as appropriate.
app.post("*", bodyParser.json({type: "application/json"}));
app.put("*", bodyParser.json({type: "application/json"}));
app.post("*", bodyParser.urlencoded({type: "application/x-www-form-urlencoded", extended: true}));

如果 body 参数始终在一个哈希表中,那么可以通过检查值是否为 undefined (typeof req.body === "undefined") 来更轻松地处理它们。如果值为 undefined,则为 body 放入一个空 hash 表 ({})。

获知期望了解的信息

定义不同的参数后,下一步是使用捕获的请求信息来实际了解应用程序想要何种输入。一般而言,我们想要的输入分为 3 类:

  • 多选
  • 数字,具有最小值和最大值
  • 自由格式文本

还有其他可能,比如日期和电话号码,但它们不太常见。在本文中,为了简便起见,我忽略了这些可能性,但您可以自由探索它们。

有许多可能的机器学习算法,但出于我们的目的,我们可以使用一个非常简单的算法:

  1. 首先假设每个输入字段都是多选的,并会跟踪收到的值。
  2. 如果值的数量高于某个阈值,则假设此值为数字或自由格式文本(基于现有值)。

在示例应用程序中,可以访问 https://machine-learning-front-end.mybluemix.net/manual/<field>/<value>,向 /manual URL 添加一个值(而且有可能会添加一个字段)(通常,如果从浏览器访问,则使用 GET 方法)。此 URL 使用输入表 inputValuesTable 的 JSON 格式作为响应。如果您看到不是自己创建的字段和值,那么有可能是另一位读者同时在使用示例应用程序。

还可以从 https://machine-learning-front-end.mybluemix.net/inputValuesTable 获取该表。如果想删除该表,可使用 https://machine-learning-front-end.mybluemix.net/reset

inputValuesTable 添加值的函数名为 add2Input()。它很长,但概念很简单。可以在源代码中查看它。

路径组件

任何路径组件都可以用作输入字段,但通常在输入前有至少一个固定字符串。可能有更多字符串,但可将它们视为一种可能只有一个值的多选输入。

请注意,此方法可能导致漏报。例如,如果两个充当服务的路径分别为 /rest/int/:integer/rest/str/:string,该算法将认为第二个路径组件是一个多选输入(int 或 str),第三个组件是自由格式文本 - 即使它是一个数字。但是,如果第二个值被视为固定值,比如 /rest/:str/:int,那么结果将产生大量的 URL。稍后在 从原型过渡到生产 部分将解释一个更复杂的算法。

对于路径组件,URL 是第一个路径组件,其他路径组件根据它们的顺序来获取字段名称。要实现路径组件顺序,可以在 app.all("*", function …) 调用中使用以下代码:

	// Treat path components as input fields
	var pathComponents = req.path.split("/");	
	for(var i=2; i<pathComponents.length; i++)
		processInput(req.method, "/" + pathComponents[1], i, pathComponents[i]);

请注意,URL 的路径始终以一个斜杠开头,所以 req.path.split("/") 返回的数组中的第一个值始终是空的。第二个值是实际的第一个组件,其余值被视为字段。

表单输入

表单输入是一个字段和值列表。添加它们非常简单 - 唯一的问题是确保 req.body(如果存在)是表单输入,而不是 JSON REST 调用的结果。可以通过 mime 类型单独表示它们:

	// If relevant, treat query (GET) and body (POST) fields as fields
	for (var field in req.query)
		processInput(req.method, req.path, field, req.query[field]);
	if (req.headers["content-type"] === "application/x-www-form-urlencoded")
		for (var bodyField in req.body)
			processInput(req.method, req.path, bodyField, req.body[bodyField]);

JSON REST 调用

JSON 是一个更难的问题,因为字段本身可能包含字段。为了将 JSON 结构转换为平面哈希表,我们使用了递归性的 flatten() 函数:

// Flatten a structure into fields and values
var flatten = function(name, data) {
	var retVal = {};
	
	if (typeof data === "object") {
		// Hash or list
		if (Array.isArray(data)) {
			// List
			for(var i=0; i<data.length; i++)
				retVal = Object.assign(retVal, flatten(name + "-" + i, data[i]));
		} else {
			// Hash
			for (var field in data)
				retVal = Object.assign(retVal, flatten(name + "-" + field, data[field]));			
		}
	} else {
		// This is a scalar value
		retVal[name] = data;
	}
	
	return retVal;
};

app.all("*", function …) 中的这段代码调用了 flatten(),然后处理所有字段:

	// Flatten and process JSON is received
	if (req.headers["content-type"] === "application/json") {
		var fields = flatten("body", req.body);
		for (var jsonField in fields)
			processInput(req.method, req.path, jsonField, fields[jsonField]);
	}

处理无效输入

目前为止,processInput() 函数仅调用 add2Input()。但是,知道想要何种输入本身并不能提供任何保护。要获得实际的安全保护,必须查看字段并参照已知选项来检查它的值。

此任务由函数 checkField() 完成。此函数也非常简单,它检查 4 种可能的错误条件,并对每个条件抛出一个异常:

  1. 类型为 num,值不是数字。
  2. 类型为 num,值太小(小于最小值)。
  3. 类型为 num,值太大(大于最大值)。
  4. 类型为 mchoice,值不是任何已知值。

processInput() 函数需要决定是添加到输入表 (add2Input()) 还是检查字段的值 (checkField())。在示例应用程序中,该函数会检查字段是否已知,如果已知,该字段是否已出现 5 次或 5 次以上(可以在源代码文件 app.js 的顶部附近修改此值):

// Process input. Either add it to the table, or verify it is legitimate
var processInput = function(method, url, field, value) {
	var key = method + ":" + url + ":" + field;
	var tableEntry = inputValuesTable[key];
	
	// We haven't seen this field yet, add it
	if (typeof tableEntry === "undefined")
		add2Input(key, value);	
	else {
		// If we haven't seen it enough times to think we know this field,
		// add this value
		if (tableEntry.count < count4Known)
			add2Input(key, value);	
		else
		// If we think we know the legitimate values, check it
			checkField(key, value);
	}  // If the type isn't undefined, meaning we've seen the field at least once
};

最后,在 app.all("*", …) 调用中,处理 checkField() 抛出的异常。现在,该应用程序将错误返回给用户,这非常适合演示目的。在真实的实现中,您可能想向潜在的攻击者提供更少的信息,并记录所有违规。

// Catch all HTTP requests
app.all("*", /* @callback */ function(req, res, next) {
	.
	.
	.	
	// Try processing the input fields. If any of them are invalid, catch the
	// error and send it to the user
	try {	
		// Treat path components as input fields (except the first one)
		var pathComponents = req.path.split("/");	
		for(var i=2; i<pathComponents.length; i++)
			processInput(req.method, "/" + pathComponents[1], i, pathComponents[i]);
		
		// If relevant, treat query (GET) and body (POST) fields as fields
		for (var field in req.query)
			processInput(req.method, req.path, field, req.query[field]);
		if (req.headers["content-type"] === "application/x-www-form-urlencoded")
			for (var bodyField in req.body)
				processInput(req.method, req.path, bodyField, req.body[bodyField]);

		// Flatten and process JSON is received
		if (req.headers["content-type"] === "application/json") {
			var fields = flatten("body", req.body);
			for (var jsonField in fields)
				processInput(req.method, req.path, jsonField, fields[jsonField]);
		}
	} catch (e) {
		res.send("" + e);
		return ;  // Make sure we return without calling next(), so the invalid request
		          // will not be processed
	}

		
	next();
});

从原型过渡到生产

本文中提供的示例应用程序被编写为一种原型来演示该技术,不适用于生产目的。(我不推荐读者在没有执行建议更改的情况下就将此应用程序用在生产环境中。)一些非常简单的更改就可以给应用程序带来明显的改进。

路径组件

更复杂的算法将每个路径组件视为一系列固定值,直到有足够的值,然后再将它转换为一个文本字段。例如,如果看到以下路径,该算法会假设每个值都是一个固定 URL:

  • /rest/strCompare/a/b
  • /rest/strCompare/a/c
  • /rest/strCompare/b/c

当算法看到有大量 /rest/strCompare/<string>/<string> 形式的不同 URL 时,它会断定只有 /rest/strCompare 是实际的 URL,其他两个字符串都是输入字段。

扩大数字范围

根据具体应用,允许使用比观察到的最小值稍低或比观察到的最大值稍高的数字值是一个不错的主意。此方法的问题在于,攻击者可逐步扩大范围,直到包含有问题的值。

存储:内存还是数据库?

通常,生产中的数据存储在数据库中,以便在应用程序重启时不会擦除它们。在这些 developerWorks 文章中,我在内存中使用哈希表的唯一原因是简单。但是,在这种情况下,有合理的理由使用内存而不是数据库。内存会在应用程序重启时(比如由于代码更改了)被擦除。在这种情况下,需要重新获知这些输入字段,因为它们的名称和含义可能已发生更改。

另一方面,这么做意味着对于应用程序重启后的前几次使用,受到的保护程度较低,因为系统仍在重新获知输入字段,所以它会认为所有输入都合法并接受它们。这是安全性与不需要手动指出应用程序已更改的便捷性之间的一种权衡。

查看缺少何种输入

当前的算法只会检查现有字段是否拥有合法的值。但在客户端没有发送应用程序想要的字段时该怎么办?可以修改 app.all("*", function …) 调用来跟踪所提供的所有字段,然后在任何这些字段意外缺失时发出警报。

允许?记录?拦截?

目前,该前端允许在获知输入字段期间进行的所有访问,然后拦截所有不允许的值。这对演示来讲已经足够了,但在真实的应用程序中,可能会导致太多误报(在这些情况下,尽管没有被攻击,但仍会检测到攻击),导致应用程序不实用。

最好跟踪一个输入字段的信息被验证的总次数,而且在大部分情况下仅记录该信息(以便某人能够查看获知的值是否需要调整) - 仅在值明显无效时拦截访问,例如由于为该字段传递了数千个合法值。

结束语

本文中的技术是对使用技术解决人类问题(程序员忘记解决问题)的一次尝试。像大多数类似尝试一样,该尝试还不够完美,而且容易出错。但是,它仍能改善安全状态,尤其是在针对您的使用场景进行适当调优时。


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Security, Big data and analytics
ArticleID=1047915
ArticleTitle=创建基于安全性的机器学习前端
publish-date=08022017