@memberjunction/global 6.2.0-edge.4 → 6.2.0-edge.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ClassFactory.d.ts +16 -3
- package/dist/ClassFactory.d.ts.map +1 -1
- package/dist/ClassFactory.js +112 -48
- package/dist/ClassFactory.js.map +1 -1
- package/dist/DeepDiff.d.ts +2 -0
- package/dist/DeepDiff.d.ts.map +1 -1
- package/dist/DeepDiff.js +40 -38
- package/dist/DeepDiff.js.map +1 -1
- package/dist/ObjectCache.d.ts +1 -0
- package/dist/ObjectCache.d.ts.map +1 -1
- package/dist/ObjectCache.js +14 -22
- package/dist/ObjectCache.js.map +1 -1
- package/dist/SQLExpressionValidator.d.ts +36 -32
- package/dist/SQLExpressionValidator.d.ts.map +1 -1
- package/dist/SQLExpressionValidator.js +125 -117
- package/dist/SQLExpressionValidator.js.map +1 -1
- package/dist/SQLScreeningScanner.d.ts +91 -0
- package/dist/SQLScreeningScanner.d.ts.map +1 -0
- package/dist/SQLScreeningScanner.js +332 -0
- package/dist/SQLScreeningScanner.js.map +1 -0
- package/dist/__tests__/SQLExpressionValidator.security.test.js +176 -2
- package/dist/__tests__/SQLExpressionValidator.security.test.js.map +1 -1
- package/dist/__tests__/SQLScreeningScanner.test.d.ts +2 -0
- package/dist/__tests__/SQLScreeningScanner.test.d.ts.map +1 -0
- package/dist/__tests__/SQLScreeningScanner.test.js +172 -0
- package/dist/__tests__/SQLScreeningScanner.test.js.map +1 -0
- package/dist/__tests__/UUIDCompliance.test.js +3 -3
- package/dist/__tests__/UUIDCompliance.test.js.map +1 -1
- package/dist/__tests__/util.valuesEqual.test.d.ts +2 -0
- package/dist/__tests__/util.valuesEqual.test.d.ts.map +1 -0
- package/dist/__tests__/util.valuesEqual.test.js +37 -0
- package/dist/__tests__/util.valuesEqual.test.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/util.d.ts +25 -0
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +38 -0
- package/dist/util.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Dialect-aware SQL scanner for MemberJunction's SQL denylist screens.
|
|
3
|
+
*
|
|
4
|
+
* A denylist screen is only as good as its idea of which text is code. This scanner reads SQL the
|
|
5
|
+
* way SQL Server or PostgreSQL splits it into tokens and marks every string literal, quoted
|
|
6
|
+
* identifier and comment, so a screen checks exactly the text the database runs as code. When it
|
|
7
|
+
* cannot read the text the way the database would, it reports that, so the screen rejects the text
|
|
8
|
+
* instead of guessing.
|
|
9
|
+
*
|
|
10
|
+
* Used by `SQLExpressionValidator` and `DatabaseProviderBase.ValidateUserProvidedSQLClause`.
|
|
11
|
+
*
|
|
12
|
+
* @module @memberjunction/global/SQLScreeningScanner
|
|
13
|
+
*/
|
|
14
|
+
/** SQL dialects whose lexical rules {@link ScanSQLForScreening} knows. */
|
|
15
|
+
export type SQLScreeningDialect = 'sqlserver' | 'postgresql';
|
|
16
|
+
/**
|
|
17
|
+
* Every dialect {@link ScanSQLForScreening} knows. A screen that does not know which database will
|
|
18
|
+
* run the SQL must accept it under each one.
|
|
19
|
+
*/
|
|
20
|
+
export declare const SQL_SCREENING_DIALECTS: readonly SQLScreeningDialect[];
|
|
21
|
+
/** Kinds of text that the database never runs as code. */
|
|
22
|
+
export type SQLScreeningSpanKind = 'string' | 'identifier' | 'comment';
|
|
23
|
+
/** One string literal, quoted identifier or comment found by {@link ScanSQLForScreening}. */
|
|
24
|
+
export interface SQLScreeningSpan {
|
|
25
|
+
Kind: SQLScreeningSpanKind;
|
|
26
|
+
/** Offset of the first character, including a prefix such as `N` or `E`. */
|
|
27
|
+
Start: number;
|
|
28
|
+
/** Offset just past the last character. */
|
|
29
|
+
End: number;
|
|
30
|
+
}
|
|
31
|
+
/** Result of {@link ScanSQLForScreening}. */
|
|
32
|
+
export interface SQLScreeningScan {
|
|
33
|
+
/** The dialect whose rules were applied. */
|
|
34
|
+
Dialect: SQLScreeningDialect;
|
|
35
|
+
/** True when every character was read the way the database reads it. When false, reject the text. */
|
|
36
|
+
Classified: boolean;
|
|
37
|
+
/** Why the text could not be classified; null when {@link Classified} is true. */
|
|
38
|
+
Error: string | null;
|
|
39
|
+
/** The string literals, quoted identifiers and comments found, in order. */
|
|
40
|
+
Spans: SQLScreeningSpan[];
|
|
41
|
+
/** Number of comments found. */
|
|
42
|
+
CommentCount: number;
|
|
43
|
+
/**
|
|
44
|
+
* The input with each string literal, quoted identifier and comment replaced by one space, and a
|
|
45
|
+
* space between a number and a word written against it. Only code remains, split where the
|
|
46
|
+
* database splits it, so `N'x'UNION` reads as ` UNION` and `1UNION` as `1 UNION`. Keyword,
|
|
47
|
+
* statement-separator and comment-marker checks read this. When {@link Classified} is false,
|
|
48
|
+
* the text from the point where the scan stopped is left as written.
|
|
49
|
+
*/
|
|
50
|
+
Code: string;
|
|
51
|
+
/**
|
|
52
|
+
* {@link Code}, except each quoted identifier is written as its bare name between spaces, so
|
|
53
|
+
* `[sys].[objects]` reads as ` sys . objects `. Checks for object and function names read this,
|
|
54
|
+
* so quoting a name does not hide it.
|
|
55
|
+
*/
|
|
56
|
+
CodeWithIdentifiers: string;
|
|
57
|
+
/**
|
|
58
|
+
* The first token that is not whitespace or a comment, when that token is an unquoted word and
|
|
59
|
+
* the character after it is ASCII, so every server ends the word in the same place. Null when a
|
|
60
|
+
* literal, quoted identifier, number or symbol comes first, or there is no token. Checks for the
|
|
61
|
+
* statement's opening keyword read this.
|
|
62
|
+
*/
|
|
63
|
+
LeadingWord: string | null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Reads `sql` the way `dialect` splits it into tokens. Returns the string literals, quoted
|
|
67
|
+
* identifiers and comments it contains, and the code that remains.
|
|
68
|
+
*
|
|
69
|
+
* Both dialects: `'…'` and `N'…'` strings, in which `''` is an escaped quote and a backslash is an
|
|
70
|
+
* ordinary character; `"…"` identifiers with `""` escapes; `--` comments to the end of the line; and
|
|
71
|
+
* nested block comments. SQL Server adds `[…]` identifiers with `]]` escapes. PostgreSQL adds `E'…'`
|
|
72
|
+
* strings, in which a backslash escapes the next character, and dollar-quoted strings (`$$…$$`,
|
|
73
|
+
* `$tag$…$tag$`); a `[` is code there.
|
|
74
|
+
*
|
|
75
|
+
* 🚨 Do not add backslash escapes to plain `'…'` strings. Neither server honors them (PostgreSQL is
|
|
76
|
+
* assumed to run with `standard_conforming_strings = on`, its default since 9.1), and a scanner that
|
|
77
|
+
* does reads `x = 'a\') ; DROP TABLE t; --'` as one literal while the database runs the DROP.
|
|
78
|
+
*
|
|
79
|
+
* The scan stops and reports an error, instead of guessing, when:
|
|
80
|
+
* - a string, quoted identifier, dollar-quoted string or block comment is not closed;
|
|
81
|
+
* - the text contains a NUL character;
|
|
82
|
+
* - a `--` comment contains a line break other than `\n` or `\r\n`;
|
|
83
|
+
* - a PostgreSQL `E'…'` string is followed by another quoted segment, which PostgreSQL can join to
|
|
84
|
+
* it and read with backslash escapes;
|
|
85
|
+
* - on SQL Server, a number is followed by `e` and a word character (`1EXEC` splits two ways).
|
|
86
|
+
*
|
|
87
|
+
* @param sql The clause, expression or query to read
|
|
88
|
+
* @param dialect The dialect whose lexical rules apply
|
|
89
|
+
*/
|
|
90
|
+
export declare function ScanSQLForScreening(sql: string, dialect: SQLScreeningDialect): SQLScreeningScan;
|
|
91
|
+
//# sourceMappingURL=SQLScreeningScanner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SQLScreeningScanner.d.ts","sourceRoot":"","sources":["../src/SQLScreeningScanner.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0EAA0E;AAC1E,MAAM,MAAM,mBAAmB,GAAG,WAAW,GAAG,YAAY,CAAC;AAE7D;;;GAGG;AACH,eAAO,MAAM,sBAAsB,EAAE,SAAS,mBAAmB,EAAgC,CAAC;AAElG,0DAA0D;AAC1D,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,YAAY,GAAG,SAAS,CAAC;AAEvE,6FAA6F;AAC7F,MAAM,WAAW,gBAAgB;IAC7B,IAAI,EAAE,oBAAoB,CAAC;IAC3B,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;CACf;AAED,6CAA6C;AAC7C,MAAM,WAAW,gBAAgB;IAC7B,4CAA4C;IAC5C,OAAO,EAAE,mBAAmB,CAAC;IAC7B,qGAAqG;IACrG,UAAU,EAAE,OAAO,CAAC;IACpB,kFAAkF;IAClF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,4EAA4E;IAC5E,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAC1B,gCAAgC;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;;OAMG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,mBAAmB,EAAE,MAAM,CAAC;IAC5B;;;;;OAKG;IACH,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AA2DD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,mBAAmB,GAAG,gBAAgB,CAE/F"}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Dialect-aware SQL scanner for MemberJunction's SQL denylist screens.
|
|
3
|
+
*
|
|
4
|
+
* A denylist screen is only as good as its idea of which text is code. This scanner reads SQL the
|
|
5
|
+
* way SQL Server or PostgreSQL splits it into tokens and marks every string literal, quoted
|
|
6
|
+
* identifier and comment, so a screen checks exactly the text the database runs as code. When it
|
|
7
|
+
* cannot read the text the way the database would, it reports that, so the screen rejects the text
|
|
8
|
+
* instead of guessing.
|
|
9
|
+
*
|
|
10
|
+
* Used by `SQLExpressionValidator` and `DatabaseProviderBase.ValidateUserProvidedSQLClause`.
|
|
11
|
+
*
|
|
12
|
+
* @module @memberjunction/global/SQLScreeningScanner
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Every dialect {@link ScanSQLForScreening} knows. A screen that does not know which database will
|
|
16
|
+
* run the SQL must accept it under each one.
|
|
17
|
+
*/
|
|
18
|
+
export const SQL_SCREENING_DIALECTS = ['sqlserver', 'postgresql'];
|
|
19
|
+
const LEXICAL_RULES = {
|
|
20
|
+
sqlserver: {
|
|
21
|
+
BracketIdentifiers: true,
|
|
22
|
+
PostgresStrings: false,
|
|
23
|
+
// Letters and digits of any script. Other non-ASCII characters are not part of a word: SQL
|
|
24
|
+
// Server reads a currency symbol such as ¥ as the start of a money literal, so in `¥1SELECT`
|
|
25
|
+
// the keyword is a separate token.
|
|
26
|
+
WordStart: /[\p{L}_@#]/u,
|
|
27
|
+
WordPart: /[\p{L}\p{N}_@#$]/u,
|
|
28
|
+
BareExponent: true,
|
|
29
|
+
},
|
|
30
|
+
postgresql: {
|
|
31
|
+
BracketIdentifiers: false,
|
|
32
|
+
PostgresStrings: true,
|
|
33
|
+
WordStart: /[A-Za-z_\u0080-]/,
|
|
34
|
+
WordPart: /[A-Za-z0-9_$\u0080-]/,
|
|
35
|
+
BareExponent: false,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const DIGIT = /[0-9]/;
|
|
39
|
+
const WHITESPACE = /\s/;
|
|
40
|
+
/** Whitespace every server skips. Other Unicode spaces count as a token when finding the leading word. */
|
|
41
|
+
const ASCII_WHITESPACE = /[ \t\n\r\f\v]/;
|
|
42
|
+
const NON_ASCII = /[^\x00-\x7f]/;
|
|
43
|
+
/**
|
|
44
|
+
* A numeric literal at `lastIndex` (sticky). An exponent needs digits, so in PostgreSQL's `1e'…'`
|
|
45
|
+
* the `e` is left to open an E'…' string.
|
|
46
|
+
*/
|
|
47
|
+
const NUMBER = /0[xX][0-9A-Fa-f]*|(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?/y;
|
|
48
|
+
/** A PostgreSQL `$$` or `$tag$` delimiter at `lastIndex` (sticky). */
|
|
49
|
+
const DOLLAR_DELIMITER = /\$(?:[A-Za-z_\u0080-][A-Za-z0-9_\u0080-]*)?\$/y;
|
|
50
|
+
/** Line breaks other than `\n` and `\r\n`. Servers disagree on whether they end a `--` comment. */
|
|
51
|
+
const AMBIGUOUS_LINE_BREAKS = new Set(['\r', '\u000b', '\u000c', '\u0085', '
', '
']);
|
|
52
|
+
/**
|
|
53
|
+
* Reads `sql` the way `dialect` splits it into tokens. Returns the string literals, quoted
|
|
54
|
+
* identifiers and comments it contains, and the code that remains.
|
|
55
|
+
*
|
|
56
|
+
* Both dialects: `'…'` and `N'…'` strings, in which `''` is an escaped quote and a backslash is an
|
|
57
|
+
* ordinary character; `"…"` identifiers with `""` escapes; `--` comments to the end of the line; and
|
|
58
|
+
* nested block comments. SQL Server adds `[…]` identifiers with `]]` escapes. PostgreSQL adds `E'…'`
|
|
59
|
+
* strings, in which a backslash escapes the next character, and dollar-quoted strings (`$$…$$`,
|
|
60
|
+
* `$tag$…$tag$`); a `[` is code there.
|
|
61
|
+
*
|
|
62
|
+
* 🚨 Do not add backslash escapes to plain `'…'` strings. Neither server honors them (PostgreSQL is
|
|
63
|
+
* assumed to run with `standard_conforming_strings = on`, its default since 9.1), and a scanner that
|
|
64
|
+
* does reads `x = 'a\') ; DROP TABLE t; --'` as one literal while the database runs the DROP.
|
|
65
|
+
*
|
|
66
|
+
* The scan stops and reports an error, instead of guessing, when:
|
|
67
|
+
* - a string, quoted identifier, dollar-quoted string or block comment is not closed;
|
|
68
|
+
* - the text contains a NUL character;
|
|
69
|
+
* - a `--` comment contains a line break other than `\n` or `\r\n`;
|
|
70
|
+
* - a PostgreSQL `E'…'` string is followed by another quoted segment, which PostgreSQL can join to
|
|
71
|
+
* it and read with backslash escapes;
|
|
72
|
+
* - on SQL Server, a number is followed by `e` and a word character (`1EXEC` splits two ways).
|
|
73
|
+
*
|
|
74
|
+
* @param sql The clause, expression or query to read
|
|
75
|
+
* @param dialect The dialect whose lexical rules apply
|
|
76
|
+
*/
|
|
77
|
+
export function ScanSQLForScreening(sql, dialect) {
|
|
78
|
+
return new SQLScreeningScanner(sql, dialect).Scan();
|
|
79
|
+
}
|
|
80
|
+
/** One left-to-right pass over the input, with the dialect's rules. */
|
|
81
|
+
class SQLScreeningScanner {
|
|
82
|
+
constructor(sql, dialect) {
|
|
83
|
+
this.sql = sql;
|
|
84
|
+
this.dialect = dialect;
|
|
85
|
+
this.spans = [];
|
|
86
|
+
this.code = [];
|
|
87
|
+
this.codeWithIdentifiers = [];
|
|
88
|
+
this.commentCount = 0;
|
|
89
|
+
this.pos = 0;
|
|
90
|
+
/** Offset of the first token that is not whitespace or a comment, once one is reached. */
|
|
91
|
+
this.leadingTokenStart = null;
|
|
92
|
+
this.leadingWord = null;
|
|
93
|
+
this.rules = LEXICAL_RULES[dialect];
|
|
94
|
+
}
|
|
95
|
+
Scan() {
|
|
96
|
+
if (!SQL_SCREENING_DIALECTS.includes(this.dialect))
|
|
97
|
+
return this.stop(`"${this.dialect}" is not a known SQL dialect`);
|
|
98
|
+
if (this.sql.includes('\0'))
|
|
99
|
+
return this.stop('the text contains a NUL character');
|
|
100
|
+
while (this.pos < this.sql.length) {
|
|
101
|
+
const error = this.readToken();
|
|
102
|
+
if (error !== null)
|
|
103
|
+
return this.stop(error);
|
|
104
|
+
}
|
|
105
|
+
return this.result(null);
|
|
106
|
+
}
|
|
107
|
+
/** Reads the token at `pos`. Returns why the text cannot be classified, or null. */
|
|
108
|
+
readToken() {
|
|
109
|
+
const c = this.sql[this.pos];
|
|
110
|
+
const next = this.sql[this.pos + 1] ?? '';
|
|
111
|
+
if (c === '-' && next === '-')
|
|
112
|
+
return this.readLineComment();
|
|
113
|
+
if (c === '/' && next === '*')
|
|
114
|
+
return this.readBlockComment();
|
|
115
|
+
if (this.leadingTokenStart === null && !ASCII_WHITESPACE.test(c))
|
|
116
|
+
this.leadingTokenStart = this.pos;
|
|
117
|
+
if (c === "'")
|
|
118
|
+
return this.readString(this.pos + 1);
|
|
119
|
+
if ((c === 'N' || c === 'n') && next === "'")
|
|
120
|
+
return this.readString(this.pos + 2);
|
|
121
|
+
if (this.rules.PostgresStrings && (c === 'E' || c === 'e') && next === "'")
|
|
122
|
+
return this.readEscapeString();
|
|
123
|
+
if (this.rules.PostgresStrings && c === '$') {
|
|
124
|
+
const delimiter = this.dollarDelimiterAt(this.pos);
|
|
125
|
+
if (delimiter !== null)
|
|
126
|
+
return this.readDollarString(delimiter);
|
|
127
|
+
}
|
|
128
|
+
if (c === '"')
|
|
129
|
+
return this.readQuotedIdentifier('"');
|
|
130
|
+
if (this.rules.BracketIdentifiers && c === '[')
|
|
131
|
+
return this.readQuotedIdentifier(']');
|
|
132
|
+
if (DIGIT.test(c) || (c === '.' && DIGIT.test(next)))
|
|
133
|
+
return this.readNumber();
|
|
134
|
+
if (this.rules.WordStart.test(c))
|
|
135
|
+
return this.readWord();
|
|
136
|
+
this.emitCode(this.pos + 1);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
/** Reads a `'…'` string whose content starts at `contentStart`. */
|
|
140
|
+
readString(contentStart) {
|
|
141
|
+
const end = this.findQuoteEnd(contentStart, "'", false);
|
|
142
|
+
if (end === -1)
|
|
143
|
+
return 'a string literal is not closed';
|
|
144
|
+
this.emitSpan('string', end);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
/** Reads a PostgreSQL `E'…'` string, in which a backslash escapes the next character. */
|
|
148
|
+
readEscapeString() {
|
|
149
|
+
const end = this.findQuoteEnd(this.pos + 2, "'", true);
|
|
150
|
+
if (end === -1)
|
|
151
|
+
return "an E'…' string is not closed";
|
|
152
|
+
if (this.quoteFollows(end)) {
|
|
153
|
+
return "an E'…' string is followed by another quoted segment, which PostgreSQL can read as part of it";
|
|
154
|
+
}
|
|
155
|
+
this.emitSpan('string', end);
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
/** Reads a PostgreSQL dollar-quoted string that opens with `delimiter`. */
|
|
159
|
+
readDollarString(delimiter) {
|
|
160
|
+
const close = this.sql.indexOf(delimiter, this.pos + delimiter.length);
|
|
161
|
+
if (close === -1)
|
|
162
|
+
return 'a dollar-quoted string is not closed';
|
|
163
|
+
this.emitSpan('string', close + delimiter.length);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
/** Reads a `"…"` or `[…]` identifier; `close` is its closing character. */
|
|
167
|
+
readQuotedIdentifier(close) {
|
|
168
|
+
const end = this.findQuoteEnd(this.pos + 1, close, false);
|
|
169
|
+
if (end === -1)
|
|
170
|
+
return 'a quoted identifier is not closed';
|
|
171
|
+
const name = this.sql.slice(this.pos + 1, end - 1).split(close + close).join(close);
|
|
172
|
+
this.emitSpan('identifier', end, name);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
/** Reads a `--` comment, which ends at the end of the line. */
|
|
176
|
+
readLineComment() {
|
|
177
|
+
let j = this.pos + 2;
|
|
178
|
+
while (j < this.sql.length && this.sql[j] !== '\n') {
|
|
179
|
+
if (this.sql[j] === '\r' && this.sql[j + 1] === '\n')
|
|
180
|
+
break;
|
|
181
|
+
if (AMBIGUOUS_LINE_BREAKS.has(this.sql[j])) {
|
|
182
|
+
return 'a -- comment contains a line break that database servers read differently';
|
|
183
|
+
}
|
|
184
|
+
j++;
|
|
185
|
+
}
|
|
186
|
+
this.commentCount++;
|
|
187
|
+
this.emitSpan('comment', j);
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
/** Reads a block comment. Block comments nest on both SQL Server and PostgreSQL. */
|
|
191
|
+
readBlockComment() {
|
|
192
|
+
let depth = 0;
|
|
193
|
+
let j = this.pos;
|
|
194
|
+
while (j < this.sql.length) {
|
|
195
|
+
if (this.sql[j] === '/' && this.sql[j + 1] === '*') {
|
|
196
|
+
depth++;
|
|
197
|
+
j += 2;
|
|
198
|
+
}
|
|
199
|
+
else if (this.sql[j] === '*' && this.sql[j + 1] === '/') {
|
|
200
|
+
depth--;
|
|
201
|
+
j += 2;
|
|
202
|
+
if (depth === 0) {
|
|
203
|
+
this.commentCount++;
|
|
204
|
+
this.emitSpan('comment', j);
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
j++;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return 'a block comment is not closed';
|
|
213
|
+
}
|
|
214
|
+
/** Reads a number. A word written against it is a separate token, so a space is added between. */
|
|
215
|
+
readNumber() {
|
|
216
|
+
NUMBER.lastIndex = this.pos;
|
|
217
|
+
const match = NUMBER.exec(this.sql);
|
|
218
|
+
const end = this.pos + (match ? match[0].length : 1);
|
|
219
|
+
const following = this.sql[end] ?? '';
|
|
220
|
+
const afterThat = this.sql[end + 1] ?? '';
|
|
221
|
+
if (this.rules.BareExponent && (following === 'e' || following === 'E') && this.rules.WordStart.test(afterThat)) {
|
|
222
|
+
return 'a number is followed by a word that starts with E, which the server can read as part of the number';
|
|
223
|
+
}
|
|
224
|
+
this.emitCode(end);
|
|
225
|
+
if (following !== '' && this.rules.WordStart.test(following))
|
|
226
|
+
this.emitBoundary();
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
/** Reads an unquoted keyword or identifier. */
|
|
230
|
+
readWord() {
|
|
231
|
+
let j = this.pos + 1;
|
|
232
|
+
while (j < this.sql.length && this.rules.WordPart.test(this.sql[j]))
|
|
233
|
+
j++;
|
|
234
|
+
if (this.pos === this.leadingTokenStart && !NON_ASCII.test(this.sql[j] ?? '')) {
|
|
235
|
+
this.leadingWord = this.sql.slice(this.pos, j);
|
|
236
|
+
}
|
|
237
|
+
this.emitCode(j);
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Offset just past the `close` character that ends a quoted run whose content starts at `from`,
|
|
242
|
+
* or -1 when none does. A doubled `close` is an escaped one; with `backslashEscapes`, so is any
|
|
243
|
+
* character after a backslash.
|
|
244
|
+
*/
|
|
245
|
+
findQuoteEnd(from, close, backslashEscapes) {
|
|
246
|
+
let j = from;
|
|
247
|
+
while (j < this.sql.length) {
|
|
248
|
+
const ch = this.sql[j];
|
|
249
|
+
if (backslashEscapes && ch === '\\') {
|
|
250
|
+
j += 2;
|
|
251
|
+
}
|
|
252
|
+
else if (ch !== close) {
|
|
253
|
+
j++;
|
|
254
|
+
}
|
|
255
|
+
else if (this.sql[j + 1] === close) {
|
|
256
|
+
j += 2;
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
return j + 1;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return -1;
|
|
263
|
+
}
|
|
264
|
+
/** Whether only whitespace and `--` comments come between `from` and the next `'`. */
|
|
265
|
+
quoteFollows(from) {
|
|
266
|
+
let j = from;
|
|
267
|
+
while (j < this.sql.length) {
|
|
268
|
+
const ch = this.sql[j];
|
|
269
|
+
if (ch === "'")
|
|
270
|
+
return true;
|
|
271
|
+
if (ch === '-' && this.sql[j + 1] === '-') {
|
|
272
|
+
j = this.lineEnd(j);
|
|
273
|
+
}
|
|
274
|
+
else if (WHITESPACE.test(ch)) {
|
|
275
|
+
j++;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
/** Offset of the first `\n` or `\r` at or after `from`, or the input length. */
|
|
284
|
+
lineEnd(from) {
|
|
285
|
+
let j = from;
|
|
286
|
+
while (j < this.sql.length && this.sql[j] !== '\n' && this.sql[j] !== '\r')
|
|
287
|
+
j++;
|
|
288
|
+
return j;
|
|
289
|
+
}
|
|
290
|
+
/** The `$$` or `$tag$` delimiter at `at`, or null when the `$` does not open a dollar-quoted string. */
|
|
291
|
+
dollarDelimiterAt(at) {
|
|
292
|
+
DOLLAR_DELIMITER.lastIndex = at;
|
|
293
|
+
return DOLLAR_DELIMITER.exec(this.sql)?.[0] ?? null;
|
|
294
|
+
}
|
|
295
|
+
/** Copies code up to `end` into both projections. */
|
|
296
|
+
emitCode(end) {
|
|
297
|
+
const text = this.sql.slice(this.pos, end);
|
|
298
|
+
this.code.push(text);
|
|
299
|
+
this.codeWithIdentifiers.push(text);
|
|
300
|
+
this.pos = end;
|
|
301
|
+
}
|
|
302
|
+
/** Records a span up to `end` and writes a space for it, or an identifier's bare name. */
|
|
303
|
+
emitSpan(kind, end, identifierName) {
|
|
304
|
+
this.spans.push({ Kind: kind, Start: this.pos, End: end });
|
|
305
|
+
this.code.push(' ');
|
|
306
|
+
this.codeWithIdentifiers.push(identifierName === undefined ? ' ' : ` ${identifierName} `);
|
|
307
|
+
this.pos = end;
|
|
308
|
+
}
|
|
309
|
+
/** Writes a token boundary that the input leaves implicit. */
|
|
310
|
+
emitBoundary() {
|
|
311
|
+
this.code.push(' ');
|
|
312
|
+
this.codeWithIdentifiers.push(' ');
|
|
313
|
+
}
|
|
314
|
+
/** Ends the scan at `pos`, leaving the unread text as written. */
|
|
315
|
+
stop(error) {
|
|
316
|
+
this.emitCode(this.sql.length);
|
|
317
|
+
return this.result(error);
|
|
318
|
+
}
|
|
319
|
+
result(error) {
|
|
320
|
+
return {
|
|
321
|
+
Dialect: this.dialect,
|
|
322
|
+
Classified: error === null,
|
|
323
|
+
Error: error,
|
|
324
|
+
Spans: this.spans,
|
|
325
|
+
CommentCount: this.commentCount,
|
|
326
|
+
Code: this.code.join(''),
|
|
327
|
+
CodeWithIdentifiers: this.codeWithIdentifiers.join(''),
|
|
328
|
+
LeadingWord: this.leadingWord,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
//# sourceMappingURL=SQLScreeningScanner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SQLScreeningScanner.js","sourceRoot":"","sources":["../src/SQLScreeningScanner.ts"],"names":[],"mappings":"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"}
|
|
@@ -214,7 +214,8 @@ describe('SQLExpressionValidator - Security', () => {
|
|
|
214
214
|
*/
|
|
215
215
|
describe('literal stripper must match database parsing (backslash is NOT an escape)', () => {
|
|
216
216
|
it('strips a doubled-quote literal exactly, leaving injected code visible', () => {
|
|
217
|
-
|
|
217
|
+
// The literal becomes one space, which keeps the token boundary the database sees.
|
|
218
|
+
expect(StripSQLStringLiterals(`Name = 'O''Brien'`)).toBe('Name = ');
|
|
218
219
|
expect(StripSQLStringLiterals(`x = 'a\\') ; DROP TABLE Users; --'`)).toContain('DROP TABLE Users');
|
|
219
220
|
});
|
|
220
221
|
it('leaves an UNTERMINATED literal in place rather than swallowing the rest', () => {
|
|
@@ -237,7 +238,9 @@ describe('SQLExpressionValidator - Security', () => {
|
|
|
237
238
|
entityFields: ['A'],
|
|
238
239
|
});
|
|
239
240
|
expect(r.valid).toBe(false);
|
|
240
|
-
|
|
241
|
+
// The literal closes at the second quote, so the trailing quote never closes and the text
|
|
242
|
+
// is rejected before any keyword check.
|
|
243
|
+
expect(r.trigger).toBe('unclassified');
|
|
241
244
|
});
|
|
242
245
|
it('blocks a backslash-hidden WAITFOR in an aggregate expression', () => {
|
|
243
246
|
const r = validator.validate(`SUM(A)+'\\'; WAITFOR DELAY '0:0:5'; SELECT 1+'`, {
|
|
@@ -261,6 +264,177 @@ describe('SQLExpressionValidator - Security', () => {
|
|
|
261
264
|
expect(r.valid).toBe(true);
|
|
262
265
|
});
|
|
263
266
|
});
|
|
267
|
+
/**
|
|
268
|
+
* 🚨 A quote is only a literal delimiter where the database says it is. Inside a T-SQL `[…]`
|
|
269
|
+
* identifier, a comment, or a PostgreSQL `$$…$$` string it is plain text; inside a PostgreSQL
|
|
270
|
+
* `E'…'` string a backslash escapes it. A stripper that misses any of these opens a literal the
|
|
271
|
+
* database never sees and hides the statement behind it. The payloads below run a harmless
|
|
272
|
+
* second statement (`SELECT 1`) on the database they target.
|
|
273
|
+
*/
|
|
274
|
+
describe('literal stripper must agree with the database on identifiers, dialect strings and comments', () => {
|
|
275
|
+
const BRACKET_HIDDEN_STATEMENT = "Name = [a'] ; SELECT 1 ; SELECT [b']";
|
|
276
|
+
it('rejects a stacked statement hidden behind a bracket identifier containing a quote (where_clause)', () => {
|
|
277
|
+
const r = validator.validate(BRACKET_HIDDEN_STATEMENT, { context: 'where_clause' });
|
|
278
|
+
expect(r.valid).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
it('rejects a stacked statement hidden behind bracket identifiers in an aggregate', () => {
|
|
281
|
+
const r = validator.validate("COUNT(*) AS [a'] ; SELECT 1 ; SELECT COUNT(*) AS [b'], COUNT(*)", {
|
|
282
|
+
context: 'aggregate',
|
|
283
|
+
});
|
|
284
|
+
expect(r.valid).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
it('rejects a stacked statement hidden behind bracket identifiers in a full query', () => {
|
|
287
|
+
const r = validator.ValidateFullQuery("SELECT [a'] FROM T ; SELECT 1 ; SELECT [b'] FROM T");
|
|
288
|
+
expect(r.valid).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
it('rejects a stacked statement hidden behind a PostgreSQL E-string escaped quote', () => {
|
|
291
|
+
const r = validator.validate("Name = E'\\'' ; SELECT 1 ; --'", { context: 'where_clause' });
|
|
292
|
+
expect(r.valid).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
it('rejects a stacked statement hidden behind a PostgreSQL dollar-quoted string', () => {
|
|
295
|
+
const r = validator.validate("Name = $$'$$ ; SELECT 1 ; --'", { context: 'where_clause' });
|
|
296
|
+
expect(r.valid).toBe(false);
|
|
297
|
+
});
|
|
298
|
+
it('rejects a stacked statement hidden behind an E string written against an operator (PostgreSQL full query)', () => {
|
|
299
|
+
// `@@` is an operator, so PostgreSQL reads E'\'' as a one-character literal and runs SELECT 2.
|
|
300
|
+
const r = validator.ValidateFullQuery("SELECT 'a' @@E'\\'' ; SELECT 2 ; --'", 'postgresql');
|
|
301
|
+
expect(r.valid).toBe(false);
|
|
302
|
+
});
|
|
303
|
+
it('rejects a stacked statement hidden behind an apostrophe in a line comment (full query)', () => {
|
|
304
|
+
const r = validator.ValidateFullQuery("SELECT 1 -- it's\n; SELECT 2 --'");
|
|
305
|
+
expect(r.valid).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
it('rejects a stacked statement hidden behind an apostrophe in a block comment (full query)', () => {
|
|
308
|
+
const r = validator.ValidateFullQuery("SELECT 1 /* it's */ ; SELECT 2 ; /* ' */");
|
|
309
|
+
expect(r.valid).toBe(false);
|
|
310
|
+
});
|
|
311
|
+
it('keeps a keyword that directly follows a literal visible', () => {
|
|
312
|
+
// SQL Server ends N'x' at the quote, so SELECT starts a new statement.
|
|
313
|
+
const r = validator.validate("Name = N'x'SELECT 1", { context: 'where_clause' });
|
|
314
|
+
expect(r.valid).toBe(false);
|
|
315
|
+
expect(r.trigger).toBe('SELECT');
|
|
316
|
+
});
|
|
317
|
+
it('StripSQLStringLiterals leaves the statement behind a bracket identifier visible', () => {
|
|
318
|
+
expect(StripSQLStringLiterals(BRACKET_HIDDEN_STATEMENT)).toContain('SELECT 1');
|
|
319
|
+
});
|
|
320
|
+
it('keeps a keyword that directly follows a number visible', () => {
|
|
321
|
+
const r = validator.validate('ID = 1SELECT 1', { context: 'where_clause' });
|
|
322
|
+
expect(r.valid).toBe(false);
|
|
323
|
+
expect(r.trigger).toBe('SELECT');
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
/**
|
|
327
|
+
* SQL Server runs the first statement of a batch as a procedure call without EXEC, and a quoted
|
|
328
|
+
* name is valid there. So a full query must open with the SELECT or WITH keyword itself, read as
|
|
329
|
+
* a token: not a quoted name, a literal or a longer word that starts with those letters.
|
|
330
|
+
*/
|
|
331
|
+
describe('a full query must open with the SELECT or WITH keyword', () => {
|
|
332
|
+
const DIALECT_SETTINGS = [
|
|
333
|
+
['sqlserver', 'sqlserver'],
|
|
334
|
+
['postgresql', 'postgresql'],
|
|
335
|
+
['no dialect', undefined],
|
|
336
|
+
];
|
|
337
|
+
const OPENERS = [
|
|
338
|
+
"[sp_executesql] N'SELECT 2' SELECT 1",
|
|
339
|
+
'[sp_who] SELECT 1',
|
|
340
|
+
`"sp_executesql" N'SELECT 2' SELECT 1`,
|
|
341
|
+
'"sp_who" SELECT 1',
|
|
342
|
+
"N'x' SELECT 1",
|
|
343
|
+
'1 SELECT 1',
|
|
344
|
+
'WithdrawFunds 1',
|
|
345
|
+
'SelectiveDelete 5',
|
|
346
|
+
];
|
|
347
|
+
for (const [label, dialect] of DIALECT_SETTINGS) {
|
|
348
|
+
it.each(OPENERS)(`refuses %s (${label})`, sql => {
|
|
349
|
+
const r = validator.ValidateFullQuery(sql, dialect);
|
|
350
|
+
expect(r.valid).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
it.each([
|
|
354
|
+
'select Name from __mj.vwUsers',
|
|
355
|
+
'-- header\n/* block */\nWITH cte AS (SELECT 1 AS One) SELECT One FROM cte',
|
|
356
|
+
])('accepts %s', sql => {
|
|
357
|
+
expect(validator.ValidateFullQuery(sql, 'sqlserver').valid).toBe(true);
|
|
358
|
+
expect(validator.ValidateFullQuery(sql, 'postgresql').valid).toBe(true);
|
|
359
|
+
});
|
|
360
|
+
const CASE_MAPPED_OPENERS = [
|
|
361
|
+
['SELECT', '\u017Felect 1'],
|
|
362
|
+
['WITH', 'w\u0131th x AS (SELECT 1 AS One) SELECT One FROM x'],
|
|
363
|
+
];
|
|
364
|
+
for (const [label, dialect] of DIALECT_SETTINGS) {
|
|
365
|
+
it.each(CASE_MAPPED_OPENERS)(`refuses an opening word that matches %s only under Unicode case mapping (${label})`, (_keyword, sql) => {
|
|
366
|
+
const r = validator.ValidateFullQuery(sql, dialect);
|
|
367
|
+
expect(r.valid).toBe(false);
|
|
368
|
+
expect(r.error).toBe('Ad-hoc query must start with SELECT or WITH');
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
it('accepts SELECT and WITH in any ASCII case', () => {
|
|
372
|
+
for (const sql of ['SeLeCt 1 AS One', 'wItH cte AS (SELECT 1 AS One) SELECT One FROM cte']) {
|
|
373
|
+
expect(validator.ValidateFullQuery(sql, 'sqlserver').valid).toBe(true);
|
|
374
|
+
expect(validator.ValidateFullQuery(sql, 'postgresql').valid).toBe(true);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
/**
|
|
379
|
+
* SQL Server reads a currency symbol such as ¥, £ or € as the start of a money literal, so the
|
|
380
|
+
* keyword written directly after the amount is a separate token.
|
|
381
|
+
*/
|
|
382
|
+
describe('a keyword written against a currency-prefixed number', () => {
|
|
383
|
+
it.each(['sqlserver', undefined])('stays visible in a where clause (%s)', dialect => {
|
|
384
|
+
const r = validator.validate('ID = ¥1SELECT 1', { context: 'where_clause', Dialect: dialect });
|
|
385
|
+
expect(r.valid).toBe(false);
|
|
386
|
+
expect(validator.validate('ID = £1EXEC sp_who', { context: 'where_clause', Dialect: dialect }).valid).toBe(false);
|
|
387
|
+
});
|
|
388
|
+
it.each(['sqlserver', undefined])('stays visible in a full query (%s)', dialect => {
|
|
389
|
+
expect(validator.ValidateFullQuery('SELECT £1EXEC sp_who', dialect).valid).toBe(false);
|
|
390
|
+
expect(validator.ValidateFullQuery("SELECT €1WAITFOR DELAY '00:00:01'", dialect).valid).toBe(false);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
describe('dialect-aware reading', () => {
|
|
394
|
+
const POSSESSIVE_ALIAS = "SELECT COUNT(*) AS [Today's Count] FROM __mj.vwUsers WHERE Status = 'Active'";
|
|
395
|
+
it('accepts a bracket identifier that contains a quote when the dialect is SQL Server', () => {
|
|
396
|
+
expect(validator.ValidateFullQuery(POSSESSIVE_ALIAS, 'sqlserver').valid).toBe(true);
|
|
397
|
+
});
|
|
398
|
+
it('rejects it when the dialect is unknown, because PostgreSQL reads the quote as a literal', () => {
|
|
399
|
+
const r = validator.ValidateFullQuery(POSSESSIVE_ALIAS);
|
|
400
|
+
expect(r.valid).toBe(false);
|
|
401
|
+
expect(r.trigger).toBe('unclassified');
|
|
402
|
+
});
|
|
403
|
+
it('accepts E strings and dollar-quoted strings when the dialect is PostgreSQL', () => {
|
|
404
|
+
const ctx = { context: 'where_clause', Dialect: 'postgresql' };
|
|
405
|
+
expect(validator.validate("Name = E'it\\'s'", ctx).valid).toBe(true);
|
|
406
|
+
expect(validator.validate("Name = $$it's$$", ctx).valid).toBe(true);
|
|
407
|
+
});
|
|
408
|
+
it('does not read keywords inside quoted identifiers', () => {
|
|
409
|
+
expect(validator.validate('[Delete Flag] = 1', { context: 'where_clause', Dialect: 'sqlserver' }).valid).toBe(true);
|
|
410
|
+
expect(validator.validate('"Update" = 1', { context: 'where_clause' }).valid).toBe(true);
|
|
411
|
+
});
|
|
412
|
+
it('accepts an apostrophe in the header comment of a full query', () => {
|
|
413
|
+
const r = validator.ValidateFullQuery("-- Customer's open orders\nSELECT * FROM __mj.vwOrders WHERE Status = 'Open'");
|
|
414
|
+
expect(r.valid).toBe(true);
|
|
415
|
+
});
|
|
416
|
+
it('accepts a trailing semicolon followed by a comment in a full query', () => {
|
|
417
|
+
expect(validator.ValidateFullQuery('SELECT Name FROM __mj.vwUsers ORDER BY Name; -- by name').valid).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
it.each([
|
|
420
|
+
'SELECT * FROM [INFORMATION_SCHEMA].[TABLES]',
|
|
421
|
+
'SELECT name FROM [sys].[sql_logins]',
|
|
422
|
+
'SELECT * FROM "pg_catalog"."pg_authid"',
|
|
423
|
+
])('catches a system catalog written with quoted names: %s', sql => {
|
|
424
|
+
const r = validator.ValidateFullQuery(sql);
|
|
425
|
+
expect(r.valid).toBe(false);
|
|
426
|
+
expect(r.trigger).toBe('system-object');
|
|
427
|
+
});
|
|
428
|
+
it('checks a quoted function name against the allowlist', () => {
|
|
429
|
+
const r = validator.validate('[dbo].[fnSecret](1) = 1', { context: 'where_clause', Dialect: 'sqlserver' });
|
|
430
|
+
expect(r.valid).toBe(false);
|
|
431
|
+
expect(r.trigger).toBe('FNSECRET');
|
|
432
|
+
});
|
|
433
|
+
it('StripSQLStringLiterals reads the given dialect, and removes nothing when the dialects disagree', () => {
|
|
434
|
+
expect(StripSQLStringLiterals("x = E'\\'' ; SELECT 1", 'postgresql')).toBe('x = ; SELECT 1');
|
|
435
|
+
expect(StripSQLStringLiterals("x = E'\\'' ; SELECT 1")).toBe("x = E'\\'' ; SELECT 1");
|
|
436
|
+
});
|
|
437
|
+
});
|
|
264
438
|
// ---------------------------------------------------------------
|
|
265
439
|
// Expression-level injection (where_clause context)
|
|
266
440
|
// ---------------------------------------------------------------
|