format() 对于函数内的动态查询有多安全?

How secure is format() for dynamic queries inside a function?

在阅读了 Postgres 手册和此处的许多帖子后,我写了这个函数来考虑我发现的所有关于安全性的内容。它工作得很好,可以满足我的所有需求。

SELECT public.goods__list_json2('{"name": [true, 7, "Ad%"], "category": [true], "stock": [false, 4, 0]}', 20, 0);

[{"name": "Adventures of TRON", "category": "Atari 2600"}, {"name": "Adventure", "category": "Atari 2600"}]

我的问题是,当我使用用户输入参数创建查询时,如何将它们作为 %L 格式传递是注入安全的? 根据我的数据库设计,所有操作都是通过函数完成的,运行 大多数函数作为安全定义器只允许某些角色执行它们。

为了安全起见,我的目的是将旧函数转换为这种动态逻辑,并节省自己编写大量代码行来创建新的或特定的查询。

非常感谢经验丰富的 Postgres 开发人员可以就此给我建议。

我正在使用 Postgres 13。

CREATE FUNCTION public.goods__list_json (IN option__j jsonb, IN limit__i integer, IN offset__i integer)
    RETURNS jsonb
    LANGUAGE plpgsql
    VOLATILE 
    STRICT
    SECURITY DEFINER
    COST 1
    AS $$
DECLARE
    table__v varchar := 'public.goods_full';
  column__v varchar[] := ARRAY['id', 'id__category', 'category', 'name', 'barcode', 'price', 'stock', 'sale', 'purchase'];
    filter__v varchar[] := ARRAY['<', '>', '<=', '>=', '=', '<>', 'LIKE', 'NOT LIKE', 'ILIKE', 'NOT ILIKE', 'BETWEEN', 'NOT BETWEEN'];
    select__v varchar[];
    where__v varchar[];
  sql__v varchar;
    key__v varchar;
    format__v varchar;
    temp__v varchar;
    temp__i integer;
    betw__v varchar;
    result__j jsonb;
BEGIN
    FOR key__v IN SELECT jsonb_object_keys(option__j) LOOP
        IF key__v = ANY(column__v) THEN
            IF (option__j->key__v->0)::bool THEN
                select__v := array_append(select__v, key__v);
            END IF;
            temp__i := (option__j->key__v->1)::int;
            IF temp__i > 0 AND temp__i <= array_length(filter__v, 1) THEN
                temp__v := (option__j->key__v->>2)::varchar;
                IF temp__i >= 11 THEN
                    betw__v := (option__j->key__v->>3)::varchar;
                    format__v := format('%I %s %L AND %L', key__v, filter__v[temp__i], temp__v, betw__v);
                ELSE
                    format__v := format('%I %s %L', key__v, filter__v[temp__i], temp__v);
                END IF;
                where__v := array_append(where__v, format__v);
            END IF;
        END IF;
    END LOOP;
    sql__v := 'SELECT jsonb_agg(t) FROM (SELECT '
        || array_to_string(select__v, ', ')
        || format(' FROM %s WHERE ', table__v)
        || array_to_string(where__v, ' AND ')
        || format(' OFFSET %L LIMIT %L', offset__i, limit__i)
        || ') t';
    RAISE NOTICE 'SQL: %', sql__v;
    EXECUTE sql__v INTO result__j;
    RETURN result__j;
END;
$$;

一个警告:这种风格在SECURITY DEFINER功能中带有动态SQL可以优雅和方便。但不要过度使用它。不要这样嵌套多层函数:

  • 该样式比普通样式更容易出错 SQL。
  • SECURITY DEFINER的上下文切换有价格标签。
  • 具有 EXECUTE 的动态 SQL 无法保存和重复使用查询计划。
  • 没有“函数内联”。
  • 而且我根本不想将它用于大表上的大查询。增加的复杂性可能成为性能障碍。喜欢:以这种方式为查询计划禁用并行性。

也就是说,你的功能看起来不错,我看不出 SQL 注入的方法。 format() 被证明可以很好地连接和引用动态 SQL 的值和标识符。相反,您可能会删除一些冗余以使其更便宜。

函数参数offset__ilimit__iinteger。 SQL 通过整数注入是不可能的,实际上没有必要引用它们(即使 SQL 允许 LIMITOFFSET 的引用字符串常量。所以只是:

format(' OFFSET %s LIMIT %s', offset__i, limit__i)

此外,在验证每个 key__v 都在您的合法列名中之后 - 虽然这些都是合法的,未加引号的列名 - 没有必要 运行 通过 %I.可以只是 %s

我宁愿使用 text 而不是 varchar。没什么大不了的,但是 text 是“首选”字符串类型。

相关:

  • Format specifier for integer variables in format() for EXECUTE?

COST 1 好像太低了。 The manual:

COST execution_cost

A positive number giving the estimated execution cost for the function, in units of cpu_operator_cost. If the function returns a set, this is the cost per returned row. If the cost is not specified, 1 unit is assumed for C-language and internal functions, and 100 units for functions in all other languages. Larger values cause the planner to try to avoid evaluating the function more often than necessary.

除非您知道得更多,否则请将 COST 保留为默认值 100

单个基于集合的操作而不是所有循环

整个循环可以用单个 SELECT 语句代替。应该明显更快。 PL/pgSQL 中的作业相对昂贵。像这样:

CREATE OR REPLACE FUNCTION goods__list_json (_options json, _limit int = NULL, _offset int = NULL, OUT _result jsonb)
    RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER AS
$func$
DECLARE
   _tbl  CONSTANT text   := 'public.goods_full';
   _cols CONSTANT text[] := '{id, id__category, category, name, barcode, price, stock, sale, purchase}';   
   _oper CONSTANT text[] := '{<, >, <=, >=, =, <>, LIKE, "NOT LIKE", ILIKE, "NOT ILIKE", BETWEEN, "NOT BETWEEN"}';
   _sql           text;
BEGIN
   SELECT concat('SELECT jsonb_agg(t) FROM ('
           , 'SELECT ' || string_agg(t.col, ', '  ORDER BY ord) FILTER (WHERE t.arr->>0 = 'true')
                                               -- ORDER BY to preserve order of objects in input
           , ' FROM '  || _tbl
           , ' WHERE ' || string_agg (
                             CASE WHEN (t.arr->>1)::int BETWEEN  1 AND 10 THEN
                                format('%s %s %L'       , t.col, _oper[(arr->>1)::int], t.arr->>2)
                                  WHEN (t.arr->>1)::int BETWEEN 11 AND 12 THEN
                                format('%s %s %L AND %L', t.col, _oper[(arr->>1)::int], t.arr->>2, t.arr->>3)
                               -- ELSE NULL  -- = default - or raise exception for illegal operator index?
                             END
                           , ' AND '  ORDER BY ord) -- ORDER BY only cosmetic
           , ' OFFSET ' || _offset  -- SQLi-safe, no quotes required
           , ' LIMIT '  || _limit   -- SQLi-safe, no quotes required
           , ') t'
          )
   FROM   json_each(_options) WITH ORDINALITY t(col, arr, ord)
   WHERE  t.col = ANY(_cols)        -- only allowed column names - or raise exception for illegal column?
   INTO   _sql;

   IF _sql IS NULL THEN
      RAISE EXCEPTION 'Invalid input resulted in empty SQL string! Input: %', _options;
   END IF;
   
   RAISE NOTICE 'SQL: %', _sql;
   EXECUTE _sql INTO _result;
END
$func$;

db<>fiddle here

更短、更快并且仍然安全地对抗 SQLi.

仅在语法需要或防御 SQL 注入时才添加引号。烧毁仅过滤值。列名和运算符根据允许选项的硬连接列表进行验证。

输入是 json 而不是 jsonb。对象的顺序保留在 json 中,因此您可以确定 SELECT 列表(有意义)和 WHERE 条件(纯粹是装饰性的)中的列顺序。该函数现在观察两者。

输出 _result 仍然是 jsonb。使用 OUT 参数而不是变量。这完全是可选的,只是为了方便。 (不需要明确的 RETURN 声明。)

注意策略性地使用 concat() 以默默地忽略 NULL 和连接运算符 ||,以便 NULL 使连接的字符串为 NULL。这样,FROMWHERELIMITOFFSET 只会在需要的地方插入。 SELECT 语句在没有任何一个的情况下也有效。一个空的 SELECT 列表(也是合法的,但我认为不需要)会导致语法错误。都是有意的。
仅对 WHERE 过滤器使用 format(),以方便和引用值。参见:

该函数不再是 STRICT_limit_offset有默认值NULL,所以只需要第一个参数_options_limit_offset 可以为 NULL 或省略,然后每个都从语句中删除。

使用 text 代替 varchar

实际制作常量变量 CONSTANT(主要用于文档)。

除此之外,该函数的功能与您原来的功能相同。

我试着把我学到的所有东西都放在这里,然后我在下面想出了这个和新问题 =D。

  • 用这种方式声明 _oper '{LIKE, "NOT LIKE"}' 而不是 ARRAY['LIKE', 'NOT LIKE'] 有什么好处吗?
  • 转换为 int _limit 和 _offset,我假设没有 SQLi,对吧?
  • 这是 'IN' 和 'NOT IN' CASE 的优雅方式吗?我想知道为什么 string_agg() 允许嵌套在 concat() 中,但不在我需要使用子查询的地方。

这是一个天真的私有函数。

编辑:删除了“SECURITY DEFINER”,因为它被认为是危险的。

CREATE FUNCTION public.crud__select (IN _tbl text, IN _cols text[], IN _opts json, OUT _data jsonb)
    LANGUAGE plpgsql STRICT AS
$$
DECLARE
    _oper CONSTANT text[] := '{<, >, <=, >=, =, <>, LIKE, "NOT LIKE", ILIKE, "NOT ILIKE", BETWEEN, "NOT BETWEEN", IN, "NOT IN"}';
BEGIN
    EXECUTE (
        SELECT concat('SELECT jsonb_agg(t) FROM ('
                , 'SELECT ' || string_agg(e.col, ', ' ORDER BY ord) FILTER (WHERE e.arr->>0 = 'true')
                , ' FROM ', _tbl
                , ' WHERE ' || string_agg(
                    CASE
                        WHEN (e.arr->>1)::int BETWEEN  1 AND 10 THEN
                            format('%s %s %L', e.col, _oper[(e.arr->>1)::int], e.arr->>2)
                        WHEN (e.arr->>1)::int BETWEEN 11 AND 12 THEN
                            format('%s %s %L AND %L', e.col, _oper[(e.arr->>1)::int], e.arr->>2, e.arr->>3)
                        WHEN (e.arr->>1)::int BETWEEN 13 AND 14 THEN
                            format('%s %s (%s)', e.col, _oper[(e.arr->>1)::int], (
                                SELECT string_agg(format('%L', ee), ',') FROM json_array_elements_text(e.arr->2) ee)
                            )
          END, ' AND ')
                , ' OFFSET ' || (_opts->>'_offset')::int
                , ' LIMIT '  || (_opts->>'_limit')::int
                , ') t'
        )
    FROM json_each(_opts) WITH ORDINALITY e(col, arr, ord)
        WHERE e.col = ANY(_cols)
    ) INTO _data;
END;
$$;

然后对于 table 或视图,我为某些角色创建包装函数 executable。

CREATE FUNCTION public.goods__select (IN _opts json, OUT _data jsonb)
    LANGUAGE sql STRICT SECURITY DEFINER AS
$$
    SELECT public.crud__select(
        'public.goods_full',
        ARRAY['id', 'id__category', 'category', 'name', 'barcode', 'price', 'stock', 'sale', 'purchase'],
        _opts
    );
$$;

SELECT public.goods__select('{"_limit": 10, "name": [true, 9, "a%"], "id__category": [true, 13, [1, 2]], "category": [true]}'::json);

[{"name": "Atlantis II", "category": "Atari 2600", "id__category": 1}, .. , {"name": "Amidar", "category": "Atari 2600", "id__category": 1}]