Regex em Ruby Além do Básico, e a Armadilha do ReDoS
Objective
Praticamente todo programa Ruby usa uma expressão regular, e praticamente todo
programa Ruby usa apenas umas quatro das funcionalidades da linguagem para regex.
O resto da superfície (literais %r{}, match? versus =~, a sintaxe estendida
/x, grupos nomeados que viram variáveis locais, gsub com um Hash,
Regexp.union para padrões dinâmicos) é o que transforma um one-liner ilegível
em algo que um revisor consegue conferir. E por baixo de tudo isso existe uma
propriedade de segurança em que a maioria dos desenvolvedores nunca pensa: uma
regex é um programa, pode levar tempo exponencial em uma entrada hostil, e o
Ruby traz um controle de verdade para isso (Regexp.timeout=, desde a 3.2)
justamente porque ReDoS é uma classe real de incidente em produção, não uma
curiosidade de livro-texto.
Use Cases
- Casar URLs, caminhos, ou qualquer outra coisa cheia de caracteres
/sem uma parede de escapes\/, usando%r{...}. - Testar "essa string parece com X?" em um loop quente ou em uma validação, onde
match?evita alocar umMatchDatae evita escrever em$~. - Escrever um padrão longo o bastante para merecer comentários (uma data, um
telefone, uma linha de log) com
/x, para poder ser lido na vertical em vez de como uma fita densa única. - Extrair vários campos de uma string com grupos nomeados e lê-los como
md[:year]em vez de contar parênteses para achar$3. - Construir um padrão em tempo de execução a partir de configuração ou entrada
do usuário com
Regexp.new/Regexp.union/Regexp.escape. - Reescrever texto onde a substituição precisa ser calculada (templating,
redação, conversão de unidades): a forma com bloco de
sub/gsub. - Aceitar um padrão de busca de um usuário final, ou casar entrada não confiável contra um padrão complicado, e precisar de uma garantia forte de que uma requisição não vai travar um núcleo de CPU para sempre.
Deep Dive
Dois literais, e o caso de uso do %r{}
/pattern/ é o literal do dia a dia. %r{pattern} é a mesma coisa com um
delimitador diferente, e toda sua razão de existir é que / dentro do padrão
deixa de precisar de escape:
plaintext/https?:\/\/[^\/]+\/users\/\d+/ # leaning-toothpick syndrome %r{https?://[^/]+/users/\d+} # same pattern, readable
%r aceita qualquer par de delimitadores (%r{}, %r[], %r(), %r!!), e
recebe as mesmas opções finais que /.../. Quando o padrão vem de uma variável
em tempo de execução, use os construtores em vez disso:
plaintextRegexp.new("hello", Regexp::IGNORECASE | Regexp::EXTENDED) # options combine with | Regexp.escape("a.b*c") # => "a\\.b\\*c" (treat user text as literal) Regexp.union("GET", "POST", "PUT") # => /GET|POST|PUT/ Regexp.union("a.b", /c+/).source # => "a\\.b|(?-mix:c+)"
Regexp.union é a forma segura de construir "casar qualquer um destes":
argumentos String são escapados automaticamente, argumentos Regexp são
embutidos como estão. A única restrição que vale a pena lembrar é que grupos
de captura dentro de uma união têm numeração indefinida: os grupos de cada
alternativa são concatenados, então qual índice guarda seu valor depende de
qual ramo casou:
plaintextu = Regexp.union(/(\d+)/, /(\w+)/) u.match("abc").captures # => [nil, "abc"]
Se você precisa de capturas, monte a alternação você mesmo em vez de usar union.
match? é o teste booleano moderno
O Ruby tem quatro formas de perguntar se um padrão casa, e elas diferem no que retornam e em se mexem ou não em estado global oculto:
| Forma | Retorna | Preenche $~, $1, $&? |
|---|---|---|
re.match?(str) |
true / false |
não |
re.match(str) |
MatchData ou nil |
sim |
re =~ str |
índice ou nil |
sim |
re === str |
true / false |
sim (usado por case/when) |
plaintext"hello".match?(/l(l)o/) # => true $~ # => nil (nothing was touched) md = "hello".match(/l(l)o/) md[0] # => "llo" md[1] # => "l" md.pre_match # => "he" md.post_match # => ""
Prefira match? quando você só quer um sim/não: ele pula a construção do
MatchData e pula a contabilidade de $~, então é mensuravelmente mais rápido
em código de validação. Quando você precisa das capturas, use match e
trabalhe com o objeto MatchData retornado, não com as variáveis globais
implícitas. $~, $1, $& e Regexp.last_match são locais ao frame e à
thread, o que parece seguro até um método auxiliar chamado no meio do caminho
refazer silenciosamente algum match e seu $1 passar a significar outra coisa.
=~ e match não estão depreciados, mas ler seus resultados de variáveis
globais é um hábito que vale a pena abandonar.
Opções: i, m, x, o
plaintext/ruby/i # case-insensitive /<.*>/m # multiline: "." also matches "\n" /\A\d+\z/x # extended: whitespace and # comments in the pattern are ignored /val=#{x}/o # interpolate #{} exactly once, on first evaluation
/m em Ruby significa só ". casa quebra de linha também"; ^/$ no Ruby são
sempre âncoras de linha, diferente de Perl ou JavaScript. Use \A e \z
quando você quer dizer "início/fim da string inteira"; /\A\d+\z/ e /^\d+$/
são checagens diferentes, e a segunda aceita alegremente "1\nrm -rf /".
/x é a opção que torna padrões longos revisáveis:
plaintextPHONE = / \A (?<area>\d{3}) # area code - (?<num>\d{4}) # local number \z /x PHONE.match("555-1234")&.named_captures # => {"area" => "555", "num" => "1234"}
Sob /x, um espaço literal precisa ser escrito \ ou [ ], e um # literal
precisa ser escapado.
/o é uma armadilha disfarçada de otimização: ela congela a interpolação após
a primeira avaliação:
plaintextdef build(x) = /val=#{x}/o build("a").source # => "val=a" build("b").source # => "val=a" <-- still the first one
Só use quando o valor interpolado for genuinamente constante durante toda a vida do processo, e mesmo assim o ganho é pequeno.
Classes de caracteres e quantificadores
As abreviações, cada uma com sua gêmea negada maiúscula:
| Classe | Casa com | Negação |
|---|---|---|
\d |
dígito decimal | \D |
\h |
dígito hexadecimal (0-9a-fA-F) |
\H |
\s |
espaço em branco | \S |
\w |
caractere de palavra ([a-zA-Z0-9_]) |
\W |
\R |
quebra de linha genérica (\n, \r\n, \r, Unicode) |
(nenhuma) |
\X |
um cluster de grafema Unicode completo | (nenhuma) |
\R e \X são as que as pessoas esquecem e depois precisam. \R casa com uma
quebra de linha independente da plataforma, então "a\r\nb".scan(/\R/) produz
um elemento, não dois. \X casa com um caractere inteiro percebido pelo
usuário, incluindo marcas de combinação:
plaintexts = "é" # "é" written as e + combining acute accent s.length # => 2 (two codepoints) s.scan(/./).size # => 2 s.scan(/\X/).size # => 1 (one grapheme)
Quantificadores (*, +, {m,n}) são gulosos: consomem o máximo possível
e só devolvem caracteres quando o resto do padrão falha. Um ? no final os
torna preguiçosos, consumindo o mínimo:
plaintext"<a><b>"[/<.*>/] # => "<a><b>" greedy "<a><b>"[/<.*?>/] # => "<a>" lazy
A armadilha que pega todo mundo pelo menos uma vez: * e {0,n} permitem zero
ocorrências, então um padrão construído só com eles sempre casa, na posição
zero, com uma string vazia:
plaintext"xyz" =~ /a*/ # => 0 (not nil!) "xyz"[/a*/] # => "" "xyz".gsub(/a*/, "-") # => "-x-y-z-"
Se uma validação lê raise unless input =~ /\A\d*\z/, ela aceita a string
vazia. Use + quando você quer dizer "pelo menos um".
A alternação tem a menor precedência
| tem a menor precedência entre todos os operadores de regex, menor que a
concatenação. Então este padrão não está perguntando sobre dois tipos de céu:
plaintext/red ball|angry sky/
Ele significa (red ball)|(angry sky). Ambos casam abaixo, e "red sky" não
casa:
plaintext"red ball" =~ /red ball|angry sky/ # => 0 "angry sky" =~ /red ball|angry sky/ # => 0 "red sky" =~ /red ball|angry sky/ # => nil "red sky" =~ /red (ball|sky)/ # => 0
Sempre coloque entre parênteses uma alternação que esteja dentro de um padrão
maior. Use um grupo não capturante (?:...) quando você só quer o escopo, não
uma captura.
Grupos nomeados, e o truque de variáveis locais com =~
Capturas numeradas deixam de ser legíveis a partir de mais ou menos três grupos. Grupos nomeados resolvem isso, e funcionam de três formas:
plaintextmd = "2026-08-18".match(/(?<year>\d{4})-(?<month>\d{2})/) md[:year] # => "2026" md.named_captures # => {"year" => "2026", "month" => "08"}
Dentro do padrão, \k<name> referencia de volta um grupo nomeado; é assim que
se encontra uma palavra duplicada:
plaintext"the the cat"[/\b(?<w>\w+)\s+\k<w>\b/] # => "the the"
E existe um pedaço de mágica genuína do Ruby: quando um literal de regex
está do lado esquerdo de =~, seus grupos nomeados são atribuídos a variáveis
locais:
plaintextif /\A(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})\z/ =~ "2026-08-18" [year, month, day] # => ["2026", "08", "18"] end
Isso só funciona com um literal do lado esquerdo. re = /(?<year>\d{4})/; re =~ s não cria nada, e "2026" =~ /(?<year>\d{4})/ (string do lado esquerdo)
também não cria nada. É um truque interessante, mas conjura variáveis locais a
partir de um padrão; em código compartilhado, md = str.match(...) seguido de
md[:year] é mais fácil de acompanhar.
Substituição: a forma com Hash e a forma com bloco
gsub aceita um Hash como sua substituição, buscando cada match como uma
chave:
plaintextmap = Hash.new("?").merge("cat" => "gato", "dog" => "cachorro") "cat and dog and bird".gsub(/\w+/, map) # => "gato ? cachorro ? ?"
Definir um default no Hash (aqui via Hash.new("?")) é o que impede matches
não mapeados de virarem strings vazias, uma forma limpa de escrever uma tabela
de tradução ou redação sem um bloco.
Quando a substituição precisa ser calculada, passe um bloco. O match é entregue ao bloco como uma String:
plaintext"price: 10 and 20".gsub(/\d+/) { |m| (m.to_i * 2).to_s } # => "price: 20 and 40"
A forma com bloco também é a forma segura. Em uma string de substituição,
\0/\& significam "o match inteiro", \1..\9 significam grupos numerados,
\` e \' significam pré e pós-match; então qualquer uma dessas
sequências aparecendo nos seus dados é interpretada em vez de inserida
literalmente, e conseguir um backslash literal exige contar escapes tanto no
literal de string do Ruby quanto na passagem de substituição:
plaintext"hello".sub(/(l+)/, '[\1]') # => "he[ll]o" "price".sub(/p/, '\&\&') # => "pprice" (\& expanded twice) "a-b".gsub(/-/, '\\\\') # => "a\\b" (four backslashes for one) "a-b".gsub(/-/) { "\\" } # => "a\\b" (block form, no double pass)
Note que a forma com aspas duplas é ainda pior, porque o próprio escaping do Ruby acontece primeiro. Se seu texto de substituição é dinâmico, use o bloco.
ReDoS: por que uma regex pode travar, e o que o Ruby faz sobre isso
O motor de regex do Ruby (Onigmo) é um motor de backtracking. Quando um padrão
pode casar o mesmo texto de mais de uma forma, uma falha o obriga a voltar e
tentar a próxima divisão. Aninhe dois quantificadores e o número de divisões
cresce exponencialmente com o tamanho da entrada. O caso clássico é
/(a+)+b/ contra uma sequência longa de as sem nenhum b: o + externo pode
dividir 30 as entre suas repetições de 2^29 formas, e cada uma delas precisa
ser tentada antes de o motor poder dizer "não". Trinta caracteres, um bilhão de
tentativas. Isso é ReDoS: uma negação de serviço em que o payload é uma string
curta e de aparência inofensiva.
O Ruby tem duas defesas, e vale a pena saber o que cada uma cobre.
1. Correspondência em tempo linear por memoização (Ruby 3.2+). O Ruby 3.2 adicionou um cache de match que lembra "nesta posição, neste estado, já falhou", o que colapsa a busca exponencial para tempo linear na maioria dos padrões. O exemplo clássico é genuinamente neutralizado em um Ruby moderno:
plaintext# Ruby 3.4 (returns nil essentially instantly, at any length) /(a+)+b/ =~ ("a" * 1_000_000)
2. Regexp.timeout= (Ruby 3.2+). A otimização tem furos: ela é pulada para
padrões que usam back-references ou chamadas de subexpressão (\g<name>), e
para entradas grandes o bastante que o próprio cache seria caro demais. Esses
padrões ainda voltam a fazer backtracking exponencial:
plaintext# subexpression call (NOT memoized, still exponential on Ruby 3.4) re = /\A(?<x>a|a)\g<x>*c/ re =~ ("a" * 22) # ~0.1s re =~ ("a" * 26) # ~1.6s (4 more characters, 16x the time) re =~ ("a" * 40) # minutes
Então o controle de reforço é um limite de tempo real. Defina-o globalmente,
uma vez, na inicialização; o padrão é nil, ou seja, sem limite, então isso é
opt-in:
plaintextRegexp.timeout = 1.0 # seconds, applies process-wide begin /\A(?<x>a|a)\g<x>*c/ =~ ("a" * 40) rescue Regexp::TimeoutError => e # => "regexp match timeout" logger.warn("regex timeout: #{e.message}") end
Regexp::TimeoutError < RegexpError < StandardError, então um rescue =>
simples em um request handler o captura e você retorna um 400 em vez de
prender um worker refém.
Um timeout por padrão sobrepõe o global, o que importa para aquele padrão específico que você sabe ser caro, e para padrões que você não escreveu:
plaintext# a search box that lets users type a regex user_re = Regexp.new(params[:q], Regexp::IGNORECASE, timeout: 0.1) user_re.timeout # => 0.1 Regexp.timeout # => unchanged
Passar timeout: nil explicitamente em um Regexp derivado de literal significa
"sem limite, ignore o global", o oposto do que você normalmente quer. E note
que o timeout não se aplica ao próprio Regexp.new, só ao matching.
A regra prática: defina um Regexp.timeout global em todo processo Ruby de
longa duração, mantenha-o generoso (0,5 a 2s) para que só dispare em casos
patológicos, e trate qualquer Regexp::TimeoutError nos logs como um relatório
de bug sobre um padrão, não como ruído. Se você aceita padrões vindos de
usuários, adicione por cima um timeout apertado por Regexp.
Trade-offs
%r{}lê melhor, mas/.../é o que as pessoas esperam ver: use%rquando o padrão contém barras (URLs, caminhos) e fique com/.../no resto; trocar de delimitador para um padrão sem nenhuma barra só adiciona mais uma coisa para interpretar.match?é mais rápido e sem efeitos colaterais, mas não deixa nada para inspecionar: se você se pega chamandomatch?e depoismatchna mesma string para pegar as capturas, você pagou por dois matches; chamematchuma vez e verifique o resultado quanto anil.- O truque de variáveis locais do
=~é elegante e invisível: as variáveis aparecem sem nenhuma atribuição visível, ele não faz nada silenciosamente se a regex estiver em uma variável, e só funciona em um sentido. Ótimo em um script, questionável em código que outras pessoas mantêm. /xtorna padrões longos revisáveis ao custo de novas regras de escape: espaços e#deixam de ser literais, o que é uma nova forma de quebrar um padrão que funcionava. Vale a pena acima de umas duas linhas de padrão, raramente vale abaixo disso.- Strings de substituição são concisas; blocos de substituição são
previsíveis: a forma em string é boa para uma reescrita fixa tipo
'\1-\2', mas cada sequência de backslash é uma chance de dados serem interpretados como sintaxe. Qualquer coisa dinâmica pertence a um bloco. - A correspondência em tempo linear do Ruby elimina a maior parte do risco de
ReDoS, mas não todo ele: não cobre back-references nem chamadas de
subexpressão
\g<>, e custa memória proporcional ao tamanho do padrão vezes o tamanho da entrada, então se desliga em entradas muito grandes. Tratar "o Ruby 3.2 corrigiu o ReDoS" como algo completo é exatamente a suposição que deixa um buraco. Regexp.timeoutconverte um travamento em uma exceção, o que é uma falha estritamente melhor, mas ainda é uma falha: é uma rede de segurança, não uma correção. Também adiciona uma verificação de tempo a cada match, e um limite definido baixo demais vai disparar em uma entrada legitimamente grande sob carga, então escolha o valor pensando na sua carga real mais lenta.