1 /*
2  * Copyright (C) 2016 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License
15  */
16 package com.android.providers.contacts.sqlite;
17 
18 import android.annotation.Nullable;
19 import android.util.ArraySet;
20 import android.util.Log;
21 
22 import com.android.providers.contacts.AbstractContactsProvider;
23 
24 import com.google.common.annotations.VisibleForTesting;
25 
26 import java.util.List;
27 import java.util.concurrent.atomic.AtomicBoolean;
28 import java.util.function.Consumer;
29 
30 /**
31  * Simple SQL validator to detect uses of hidden tables / columns as well as invalid SQLs.
32  */
33 public class SqlChecker {
34     private static final String TAG = "SqlChecker";
35 
36     private static final String PRIVATE_PREFIX = "x_"; // MUST BE LOWERCASE.
37 
38     private static final boolean VERBOSE_LOGGING = AbstractContactsProvider.VERBOSE_LOGGING;
39 
40     private final ArraySet<String> mInvalidTokens;
41 
42     /**
43      * Create a new instance with given invalid tokens.
44      */
SqlChecker(List<String> invalidTokens)45     public SqlChecker(List<String> invalidTokens) {
46         mInvalidTokens = new ArraySet<>(invalidTokens.size());
47 
48         for (int i = invalidTokens.size() - 1; i >= 0; i--) {
49             mInvalidTokens.add(invalidTokens.get(i).toLowerCase());
50         }
51         if (VERBOSE_LOGGING) {
52             Log.d(TAG, "Initialized with invalid tokens: " + invalidTokens);
53         }
54     }
55 
isAlpha(char ch)56     private static boolean isAlpha(char ch) {
57         return ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z') || (ch == '_');
58     }
59 
isNum(char ch)60     private static boolean isNum(char ch) {
61         return ('0' <= ch && ch <= '9');
62     }
63 
isAlNum(char ch)64     private static boolean isAlNum(char ch) {
65         return isAlpha(ch) || isNum(ch);
66     }
67 
isAnyOf(char ch, String set)68     private static boolean isAnyOf(char ch, String set) {
69         return set.indexOf(ch) >= 0;
70     }
71 
72     /**
73      * Exception for invalid queries.
74      */
75     @VisibleForTesting
76     public static final class InvalidSqlException extends IllegalArgumentException {
InvalidSqlException(String s)77         public InvalidSqlException(String s) {
78             super(s);
79         }
80     }
81 
genException(String message, String sql)82     private static InvalidSqlException genException(String message, String sql) {
83         throw new InvalidSqlException(message + " in '" + sql + "'");
84     }
85 
throwIfContainsToken(String token, String sql)86     private void throwIfContainsToken(String token, String sql) {
87         final String lower = token.toLowerCase();
88         if (mInvalidTokens.contains(lower) || lower.startsWith(PRIVATE_PREFIX)) {
89             throw genException("Detected disallowed token: " + token, sql);
90         }
91     }
92 
93     /**
94      * Ensure {@code sql} is valid and doesn't contain invalid tokens.
95      */
ensureNoInvalidTokens(@ullable String sql)96     public void ensureNoInvalidTokens(@Nullable String sql) {
97         findTokens(sql, OPTION_NONE, token -> throwIfContainsToken(token, sql));
98     }
99 
100     /**
101      * Ensure {@code sql} only contains a single, valid token.  Use to validate column names
102      * in {@link android.content.ContentValues}.
103      */
ensureSingleTokenOnly(@ullable String sql)104     public void ensureSingleTokenOnly(@Nullable String sql) {
105         final AtomicBoolean tokenFound = new AtomicBoolean();
106 
107         findTokens(sql, OPTION_TOKEN_ONLY, token -> {
108             if (tokenFound.get()) {
109                 throw genException("Multiple tokens detected", sql);
110             }
111             tokenFound.set(true);
112             throwIfContainsToken(token, sql);
113         });
114         if (!tokenFound.get()) {
115             throw genException("Token not found", sql);
116         }
117     }
118 
119     @VisibleForTesting
120     static final int OPTION_NONE = 0;
121 
122     @VisibleForTesting
123     static final int OPTION_TOKEN_ONLY = 1 << 0;
124 
peek(String s, int index)125     private static char peek(String s, int index) {
126         return index < s.length() ? s.charAt(index) : '\0';
127     }
128 
129     /**
130      * SQL Tokenizer specialized to extract tokens from SQL (snippets).
131      *
132      * Based on sqlite3GetToken() in tokenzie.c in SQLite.
133      *
134      * Source for v3.8.6 (which android uses): http://www.sqlite.org/src/artifact/ae45399d6252b4d7
135      * (Latest source as of now: http://www.sqlite.org/src/artifact/78c8085bc7af1922)
136      *
137      * Also draft spec: http://www.sqlite.org/draft/tokenreq.html
138      */
139     @VisibleForTesting
findTokens(@ullable String sql, int options, Consumer<String> checker)140     static void findTokens(@Nullable String sql, int options, Consumer<String> checker) {
141         if (sql == null) {
142             return;
143         }
144         int pos = 0;
145         final int len = sql.length();
146         while (pos < len) {
147             final char ch = peek(sql, pos);
148 
149             // Regular token.
150             if (isAlpha(ch)) {
151                 final int start = pos;
152                 pos++;
153                 while (isAlNum(peek(sql, pos))) {
154                     pos++;
155                 }
156                 final int end = pos;
157 
158                 final String token = sql.substring(start, end);
159                 checker.accept(token);
160 
161                 continue;
162             }
163 
164             // Handle quoted tokens
165             if (isAnyOf(ch, "'\"`")) {
166                 final int quoteStart = pos;
167                 pos++;
168 
169                 for (;;) {
170                     pos = sql.indexOf(ch, pos);
171                     if (pos < 0) {
172                         throw genException("Unterminated quote", sql);
173                     }
174                     if (peek(sql, pos + 1) != ch) {
175                         break;
176                     }
177                     // Quoted quote char -- e.g. "abc""def" is a single string.
178                     pos += 2;
179                 }
180                 final int quoteEnd = pos;
181                 pos++;
182 
183                 if (ch != '\'') {
184                     // Extract the token
185                     final String tokenUnquoted = sql.substring(quoteStart + 1, quoteEnd);
186 
187                     final String token;
188 
189                     // Unquote if needed. i.e. "aa""bb" -> aa"bb
190                     if (tokenUnquoted.indexOf(ch) >= 0) {
191                         token = tokenUnquoted.replaceAll(
192                                 String.valueOf(ch) + ch, String.valueOf(ch));
193                     } else {
194                         token = tokenUnquoted;
195                     }
196                     checker.accept(token);
197                 } else {
198                     if ((options &= OPTION_TOKEN_ONLY) != 0) {
199                         throw genException("Non-token detected", sql);
200                     }
201                 }
202                 continue;
203             }
204             // Handle tokens enclosed in [...]
205             if (ch == '[') {
206                 final int quoteStart = pos;
207                 pos++;
208 
209                 pos = sql.indexOf(']', pos);
210                 if (pos < 0) {
211                     throw genException("Unterminated quote", sql);
212                 }
213                 final int quoteEnd = pos;
214                 pos++;
215 
216                 final String token = sql.substring(quoteStart + 1, quoteEnd);
217 
218                 checker.accept(token);
219                 continue;
220             }
221             if ((options &= OPTION_TOKEN_ONLY) != 0) {
222                 throw genException("Non-token detected", sql);
223             }
224 
225             // Detect comments.
226             if (ch == '-' && peek(sql, pos + 1) == '-') {
227                 pos += 2;
228                 pos = sql.indexOf('\n', pos);
229                 if (pos < 0) {
230                     // We disallow strings ending in an inline comment.
231                     throw genException("Unterminated comment", sql);
232                 }
233                 pos++;
234 
235                 continue;
236             }
237             if (ch == '/' && peek(sql, pos + 1) == '*') {
238                 pos += 2;
239                 pos = sql.indexOf("*/", pos);
240                 if (pos < 0) {
241                     throw genException("Unterminated comment", sql);
242                 }
243                 pos += 2;
244 
245                 continue;
246             }
247 
248             // Semicolon is never allowed.
249             if (ch == ';') {
250                 throw genException("Semicolon is not allowed", sql);
251             }
252 
253             // For this purpose, we can simply ignore other characters.
254             // (Note it doesn't handle the X'' literal properly and reports this X as a token,
255             // but that should be fine...)
256             pos++;
257         }
258     }
259 }
260