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