Home | History | Annotate | Download | only in sqlite
      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      */
     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 
     56     private static boolean isAlpha(char ch) {
     57         return ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z') || (ch == '_');
     58     }
     59 
     60     private static boolean isNum(char ch) {
     61         return ('0' <= ch && ch <= '9');
     62     }
     63 
     64     private static boolean isAlNum(char ch) {
     65         return isAlpha(ch) || isNum(ch);
     66     }
     67 
     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 {
     77         public InvalidSqlException(String s) {
     78             super(s);
     79         }
     80     }
     81 
     82     private static InvalidSqlException genException(String message, String sql) {
     83         throw new InvalidSqlException(message + " in '" + sql + "'");
     84     }
     85 
     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      */
     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      */
    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 
    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
    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