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 */
16package com.android.providers.contacts.sqlite;
17
18import android.annotation.Nullable;
19import android.util.ArraySet;
20import android.util.Log;
21
22import com.android.providers.contacts.AbstractContactsProvider;
23
24import com.google.common.annotations.VisibleForTesting;
25
26import java.util.List;
27import java.util.concurrent.atomic.AtomicBoolean;
28import java.util.function.Consumer;
29
30/**
31 * Simple SQL validator to detect uses of hidden tables / columns as well as invalid SQLs.
32 */
33public 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