Skip to content

Commit 7e93087

Browse files
committed
Contextual string tags to prevent SQL injection
https://nodesecroadmap.fyi/chapter-7/query-langs.html describes this approach as part of a larger discussion about library support for safe coding practices. This is one step in a larger effort to enable connection.query`SELECT * FROM T WHERE x = ${x}, y = ${y}, z = ${z}`(callback) and similar idioms. This was broken out of mysqljs/mysql#1926
1 parent 8e91922 commit 7e93087

File tree

6 files changed

+498
-0
lines changed

6 files changed

+498
-0
lines changed

index.js

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
module.exports = require('./lib/SqlString');
2+
module.exports.sql = require('./lib/Template');

lib/Template.js

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
try {
2+
module.exports = require('./es6/Template')
3+
} catch (ignored) {
4+
// ES6 code failed to load.
5+
//
6+
// This happens in Node runtimes with versions < 6.
7+
// Since those runtimes won't parse template tags, we
8+
// fallback to an equivalent API that assumes no calls
9+
// are template tag calls.
10+
//
11+
// Clients that need to work on older Node runtimes
12+
// should not use any part of this API except
13+
// calledAsTemplateTagQuick unless that function has
14+
// returned true.
15+
16+
module.exports = function (staticStrings) {
17+
// This might be reached if client code is transpiled down to
18+
// ES5 but this module is not.
19+
throw new Error('ES6 features not supported');
20+
};
21+
/**
22+
* @param {*} firstArg
23+
* @param {number} nArgs
24+
*
25+
* @return {boolean} always false in ES<6 compatibility mode.
26+
*/
27+
module.exports.calledAsTemplateTagQuick = function (firstArg, nArgs) {
28+
return false;
29+
};
30+
31+
function stringWrapper() {
32+
function TypedString(content) {
33+
if (!(this instanceof TypedString)) {
34+
return new TypedString(content);
35+
}
36+
this.content = String(content);
37+
}
38+
TypedString.prototype.toString = function () {
39+
return this.content;
40+
};
41+
return TypedString;
42+
}
43+
44+
/**
45+
* @param {string} content
46+
* @constructor
47+
*/
48+
module.exports.Identifier = stringWrapper();
49+
/**
50+
* @param {string} content
51+
* @constructor
52+
*/
53+
module.exports.Fragment = stringWrapper();
54+
}

lib/es6/Template.js

+251
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// This file uses es6 features and is loaded optimistically.
2+
3+
const SqlString = require('../SqlString');
4+
const {
5+
calledAsTemplateTagQuick,
6+
memoizedTagFunction,
7+
trimCommonWhitespaceFromLines,
8+
TypedString
9+
} = require('template-tag-common');
10+
11+
// A simple lexer for SQL.
12+
// SQL has many divergent dialects with subtly different
13+
// conventions for string escaping and comments.
14+
// This just attempts to roughly tokenize MySQL's specific variant.
15+
// See also
16+
// https://www.w3.org/2005/05/22-SPARQL-MySQL/sql_yacc
17+
// https://github.com/twitter/mysql/blob/master/sql/sql_lex.cc
18+
// https://dev.mysql.com/doc/refman/5.7/en/string-literals.html
19+
20+
// "--" followed by whitespace starts a line comment
21+
// "#"
22+
// "/*" starts an inline comment ended at first "*/"
23+
// \N means null
24+
// Prefixed strings x'...' is a hex string, b'...' is a binary string, ....
25+
// '...', "..." are strings. `...` escapes identifiers.
26+
// doubled delimiters and backslash both escape
27+
// doubled delimiters work in `...` identifiers
28+
29+
const PREFIX_BEFORE_DELIMITER = new RegExp(
30+
'^(?:' +
31+
(
32+
// Comment
33+
'--(?=[\\t\\r\\n ])[^\\r\\n]*' +
34+
'|#[^\\r\\n]*' +
35+
'|/[*][\\s\\S]*?[*]/'
36+
) +
37+
'|' +
38+
(
39+
// Run of non-comment non-string starts
40+
'(?:[^\'"`\\-/#]|-(?!-)|/(?![*]))'
41+
) +
42+
')*');
43+
const DELIMITED_BODIES = {
44+
'\'' : /^(?:[^'\\]|\\[\s\S]|'')*/,
45+
'"' : /^(?:[^"\\]|\\[\s\S]|"")*/,
46+
'`' : /^(?:[^`\\]|\\[\s\S]|``)*/
47+
};
48+
49+
/**
50+
* Template tag that creates a new Error with a message.
51+
* @param {!Array.<string>} strs a valid TemplateObject.
52+
* @return {string} A message suitable for the Error constructor.
53+
*/
54+
function msg (strs, ...dyn) {
55+
let message = String(strs[0]);
56+
for (let i = 0; i < dyn.length; ++i) {
57+
message += JSON.stringify(dyn[i]) + strs[i + 1];
58+
}
59+
return message;
60+
}
61+
62+
/**
63+
* Returns a function that can be fed chunks of input and which
64+
* returns a delimiter context.
65+
*
66+
* @return {!function (string) : string}
67+
* a stateful function that takes a string of SQL text and
68+
* returns the context after it. Subsequent calls will assume
69+
* that context.
70+
*/
71+
function makeLexer () {
72+
let errorMessage = null;
73+
let delimiter = null;
74+
return (text) => {
75+
if (errorMessage) {
76+
// Replay the error message if we've already failed.
77+
throw new Error(errorMessage);
78+
}
79+
text = String(text);
80+
while (text) {
81+
const pattern = delimiter
82+
? DELIMITED_BODIES[delimiter]
83+
: PREFIX_BEFORE_DELIMITER;
84+
const match = pattern.exec(text);
85+
if (!match) {
86+
throw new Error(
87+
errorMessage = msg`Failed to lex starting at ${text}`);
88+
}
89+
let nConsumed = match[0].length;
90+
if (text.length > nConsumed) {
91+
const chr = text.charAt(nConsumed);
92+
if (delimiter) {
93+
if (chr === delimiter) {
94+
delimiter = null;
95+
++nConsumed;
96+
} else {
97+
throw new Error(
98+
errorMessage = msg`Expected ${chr} at ${text}`);
99+
}
100+
} else if (Object.hasOwnProperty.call(DELIMITED_BODIES, chr)) {
101+
delimiter = chr;
102+
++nConsumed;
103+
} else {
104+
throw new Error(
105+
errorMessage = msg`Expected delimiter at ${text}`);
106+
}
107+
}
108+
text = text.substring(nConsumed);
109+
}
110+
return delimiter;
111+
};
112+
}
113+
114+
/** A string wrapper that marks its content as a SQL identifier. */
115+
class Identifier extends TypedString {}
116+
117+
/**
118+
* A string wrapper that marks its content as a series of
119+
* well-formed SQL tokens.
120+
*/
121+
class SqlFragment extends TypedString {}
122+
123+
/**
124+
* Analyzes the static parts of the tag content.
125+
*
126+
* @param {!Array.<string>} strings a valid TemplateObject.
127+
* @return { !{
128+
* raw: !Array.<string>,
129+
* delimiters : !Array.<string>,
130+
* chunks: !Array.<string>
131+
* } }
132+
* A record like { raw, delimiters, chunks }
133+
* where delimiter is a contextual cue and chunk is
134+
* the adjusted raw text.
135+
*/
136+
function computeStatic (strings) {
137+
const { raw } = trimCommonWhitespaceFromLines(strings);
138+
139+
const delimiters = [];
140+
const chunks = [];
141+
142+
const lexer = makeLexer();
143+
144+
let delimiter = null;
145+
for (let i = 0, len = raw.length; i < len; ++i) {
146+
let chunk = String(raw[i]);
147+
if (delimiter === '`') {
148+
// Treat raw \` in an identifier literal as an ending delimiter.
149+
chunk = chunk.replace(/^([^\\`]|\\[\s\S])*\\`/, '$1`');
150+
}
151+
const newDelimiter = lexer(chunk);
152+
if (newDelimiter === '`' && !delimiter) {
153+
// Treat literal \` outside a string context as starting an
154+
// identifier literal
155+
chunk = chunk.replace(
156+
/((?:^|[^\\])(?:\\\\)*)\\(`(?:[^`\\]|\\[\s\S])*)$/, '$1$2');
157+
}
158+
159+
chunks.push(chunk);
160+
delimiters.push(newDelimiter);
161+
delimiter = newDelimiter;
162+
}
163+
164+
if (delimiter) {
165+
throw new Error(`Unclosed quoted string: ${delimiter}`);
166+
}
167+
168+
return { raw, delimiters, chunks };
169+
}
170+
171+
function interpolateSqlIntoFragment (
172+
{ raw, delimiters, chunks }, strings, values) {
173+
// A buffer to accumulate output.
174+
let [ result ] = chunks;
175+
for (let i = 1, len = raw.length; i < len; ++i) {
176+
const chunk = chunks[i];
177+
// The count of values must be 1 less than the surrounding
178+
// chunks of literal text.
179+
if (i !== 0) {
180+
const delimiter = delimiters[i - 1];
181+
const value = values[i - 1];
182+
if (delimiter) {
183+
result += escapeDelimitedValue(value, delimiter);
184+
} else {
185+
result = appendValue(result, value, chunk);
186+
}
187+
}
188+
189+
result += chunk;
190+
}
191+
192+
return new SqlFragment(result);
193+
}
194+
195+
function escapeDelimitedValue (value, delimiter) {
196+
if (delimiter === '`') {
197+
return SqlString.escapeId(String(value)).replace(/^`|`$/g, '');
198+
}
199+
const escaped = SqlString.escape(String(value));
200+
return escaped.substring(1, escaped.length - 1);
201+
}
202+
203+
function appendValue (resultBefore, value, chunk) {
204+
let needsSpace = false;
205+
let result = resultBefore;
206+
const valueArray = Array.isArray(value) ? value : [ value ];
207+
for (let i = 0, nValues = valueArray.length; i < nValues; ++i) {
208+
if (i) {
209+
result += ', ';
210+
}
211+
212+
const one = valueArray[i];
213+
let valueStr = null;
214+
if (one instanceof SqlFragment) {
215+
if (!/(?:^|[\n\r\t ,\x28])$/.test(result)) {
216+
result += ' ';
217+
}
218+
valueStr = one.toString();
219+
needsSpace = i + 1 === nValues;
220+
} else if (one instanceof Identifier) {
221+
valueStr = SqlString.escapeId(one.toString());
222+
} else {
223+
// If we need to handle nested arrays, we would recurse here.
224+
valueStr = SqlString.format('?', one);
225+
}
226+
result += valueStr;
227+
}
228+
229+
if (needsSpace && chunk && !/^[\n\r\t ,\x29]/.test(chunk)) {
230+
result += ' ';
231+
}
232+
233+
return result;
234+
}
235+
236+
/**
237+
* Template tag function that contextually autoescapes values
238+
* producing a SqlFragment.
239+
*/
240+
const sql = memoizedTagFunction(computeStatic, interpolateSqlIntoFragment);
241+
sql.Identifier = Identifier;
242+
sql.Fragment = SqlFragment;
243+
sql.calledAsTemplateTagQuick = calledAsTemplateTagQuick;
244+
245+
if (require('process').env.npm_lifecycle_event === 'test') {
246+
// Expose for testing.
247+
// Harmless if this leaks
248+
sql.makeLexer = makeLexer;
249+
}
250+
251+
module.exports = sql;

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
"sql escape"
1919
],
2020
"repository": "mysqljs/sqlstring",
21+
"dependencies": {
22+
"template-tag-common": "1.0.8"
23+
},
2124
"devDependencies": {
2225
"beautify-benchmark": "0.2.4",
2326
"benchmark": "2.1.4",

0 commit comments

Comments
 (0)