Skip to content

Commit fdd1844

Browse files
committed
fix: bound and validate rule-embedded scripts
Signed-off-by: Joseph Kato <joseph@jdkato.io>
1 parent fe71481 commit fdd1844

4 files changed

Lines changed: 313 additions & 5 deletions

File tree

internal/check/metric.go

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package check
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"regexp"
78
"strings"
89

910
"github.com/d5/tengo/v2"
11+
"github.com/d5/tengo/v2/parser"
1012
"github.com/d5/tengo/v2/stdlib"
1113

1214
"github.com/errata-ai/vale/v3/internal/core"
@@ -50,7 +52,12 @@ func NewMetric(_ *core.Config, generic baseCheck, path string) (Metric, error) {
5052
// Run calculates the readability level of the given text.
5153
func (o Metric) Run(_ nlp.Block, f *core.File, _ *core.Config) ([]core.Alert, error) {
5254
alerts := []core.Alert{}
53-
ctx := context.Background()
55+
56+
// A formula is compiled and run as a Tengo program, so it needs the same
57+
// deadline a script rule gets; see tengoTimeout. Both evalMath calls share
58+
// it, which bounds the rule as a whole rather than each half of it.
59+
ctx, cancel := context.WithTimeout(context.Background(), tengoTimeout)
60+
defer cancel()
5461

5562
parameters, err := f.ComputeMetrics()
5663
if err != nil {
@@ -73,15 +80,17 @@ func (o Metric) Run(_ nlp.Block, f *core.File, _ *core.Config) ([]core.Alert, er
7380
// We need this to allow showing the result in a rule's message.
7481
res, err := evalMath(ctx, o.Formula, parameters)
7582
if err != nil {
76-
return alerts, core.NewE201FromTarget(err.Error(), "formula", o.path)
83+
return alerts, ruleError(
84+
o.Name, "formula", o.path, err, errors.Is(ctx.Err(), context.DeadlineExceeded))
7785
}
7886

7987
// The binary result of our formula:
8088
eqb := fmt.Sprintf("%f %s", res, o.Condition)
8189

8290
match, err := evalMath(ctx, eqb, parameters)
8391
if err != nil {
84-
return alerts, core.NewE201FromTarget(err.Error(), "condition", o.path)
92+
return alerts, ruleError(
93+
o.Name, "condition", o.path, err, errors.Is(ctx.Err(), context.DeadlineExceeded))
8594
}
8695

8796
if match.(bool) {
@@ -105,6 +114,40 @@ func (o Metric) Pattern() string {
105114
return o.Formula
106115
}
107116

117+
// checkExpression rejects anything that is not a single expression.
118+
//
119+
// A rule's formula is pasted into a Tengo program by boilerplate above, and
120+
// `%s` escapes nothing. A formula that closes the parenthesis it was handed can
121+
// therefore append statements of its own, which turns a `metric` rule -- meant
122+
// to be arithmetic over a document's counts -- into arbitrary code running in
123+
// the same VM a `script` rule gets. `0); for { } ; x := (0` is the whole exploit,
124+
// and the same applies to `condition`, which is spliced after a number.
125+
//
126+
// Parsing the formula on its own settles it. An injection cannot survive the
127+
// trip: the `)` it depends on has no opener until the boilerplate supplies one,
128+
// so it fails to parse here, where it is still just a string.
129+
func checkExpression(expr string) error {
130+
fileSet := parser.NewFileSet()
131+
srcFile := fileSet.AddFile("expression", -1, len(expr))
132+
133+
parsed, err := parser.NewParser(srcFile, []byte(expr), nil).ParseFile()
134+
if err != nil {
135+
return fmt.Errorf("invalid expression %q: %w", expr, err)
136+
}
137+
138+
if len(parsed.Stmts) != 1 {
139+
return fmt.Errorf(
140+
"expected a single expression, found %d statements in %q",
141+
len(parsed.Stmts), expr)
142+
}
143+
if _, ok := parsed.Stmts[0].(*parser.ExprStmt); !ok {
144+
return fmt.Errorf(
145+
"expected an expression, found %T in %q", parsed.Stmts[0], expr)
146+
}
147+
148+
return nil
149+
}
150+
108151
func evalMath(
109152
ctx context.Context,
110153
expr string,
@@ -115,6 +158,10 @@ func evalMath(
115158
return nil, fmt.Errorf("empty expression")
116159
}
117160

161+
if err := checkExpression(expr); err != nil {
162+
return nil, err
163+
}
164+
118165
script := tengo.NewScript([]byte(fmt.Sprintf(boilerplate, expr)))
119166
script.SetImports(stdlib.GetModuleMap("math"))
120167

internal/check/metric_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package check
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
"time"
8+
)
9+
10+
// A metric's formula is pasted into a Tengo program, so a formula that closes
11+
// the parenthesis it is handed can append statements and get the whole VM. The
12+
// timeout bounds how long that costs; this is what stops it being possible.
13+
func TestEvalMathRejectsInjectedStatements(t *testing.T) {
14+
injections := []string{
15+
// Closes the boilerplate's parenthesis, loops, reopens it.
16+
"0); for { } ; x := (0",
17+
// The same shape without the loop: still two statements smuggled in.
18+
"0); x := 1; y := (0",
19+
// A bare statement rather than an expression.
20+
"x := 1",
21+
}
22+
23+
for _, expr := range injections {
24+
t.Run(expr, func(t *testing.T) {
25+
_, err := evalMath(context.Background(), expr, map[string]interface{}{})
26+
if err == nil {
27+
t.Fatalf("%q was accepted", expr)
28+
}
29+
if strings.Contains(err.Error(), "deadline") {
30+
t.Errorf("%q ran and was stopped by the timeout; it should not "+
31+
"have compiled: %v", expr, err)
32+
}
33+
})
34+
}
35+
}
36+
37+
// The formulas that ship with Vale have to keep working, including the
38+
// multi-line ones and the ones calling into `math`.
39+
func TestEvalMathAcceptsRealFormulas(t *testing.T) {
40+
params := map[string]interface{}{
41+
"words": 100.0, "sentences": 10.0, "syllables": 150.0,
42+
"long_words": 20.0, "polysyllabic_words": 5.0, "characters": 500.0,
43+
}
44+
45+
formulas := []string{
46+
"words / sentences",
47+
"(words / sentences) + ((long_words * 100) / words)",
48+
"(0.39 * (words / sentences)) + (11.8 * (syllables / words)) - 15.59",
49+
"1.0430 * math.sqrt((polysyllabic_words * 30.0) / sentences) + 3.1291",
50+
// The block-scalar forms arrive with surrounding whitespace.
51+
"\n words / sentences\n",
52+
}
53+
54+
for _, expr := range formulas {
55+
t.Run(strings.TrimSpace(expr), func(t *testing.T) {
56+
if _, err := evalMath(context.Background(), expr, params); err != nil {
57+
t.Errorf("rejected a valid formula: %v", err)
58+
}
59+
})
60+
}
61+
}
62+
63+
// `condition` is spliced in after the computed value, so it is the same hole
64+
// by another route and has to be closed by the same check.
65+
func TestEvalMathGuardsTheConditionPath(t *testing.T) {
66+
// What Metric.Run builds: the result, then the rule's condition.
67+
good := "12.500000 > 10"
68+
if _, err := evalMath(context.Background(), good, map[string]interface{}{}); err != nil {
69+
t.Errorf("rejected a valid condition: %v", err)
70+
}
71+
72+
bad := "12.500000 > 0); for { } ; x := (0"
73+
_, err := evalMath(context.Background(), bad, map[string]interface{}{})
74+
if err == nil {
75+
t.Fatal("an injected condition was accepted")
76+
}
77+
if strings.Contains(err.Error(), "deadline") {
78+
t.Errorf("the injected condition ran: %v", err)
79+
}
80+
}
81+
82+
// Rejection has to happen before execution, not by running the program and
83+
// waiting for the deadline: a formula stopped by the timeout still ran.
84+
func TestEvalMathRejectsWithoutRunning(t *testing.T) {
85+
ctx, cancel := context.WithTimeout(context.Background(), tengoTimeout)
86+
defer cancel()
87+
88+
start := time.Now()
89+
if _, err := evalMath(ctx, "0); for { } ; x := (0", map[string]interface{}{}); err == nil {
90+
t.Fatal("expected the expression to be rejected")
91+
}
92+
93+
if elapsed := time.Since(start); elapsed > time.Second {
94+
t.Errorf("took %s, so it was executed and timed out rather than refused",
95+
elapsed)
96+
}
97+
}

internal/check/script.go

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package check
22

33
import (
4+
"context"
5+
"errors"
6+
"fmt"
47
"os"
58
"strings"
9+
"time"
610

711
"github.com/d5/tengo/v2"
812
"github.com/d5/tengo/v2/stdlib"
@@ -11,6 +15,44 @@ import (
1115
"github.com/errata-ai/vale/v3/internal/nlp"
1216
)
1317

18+
// tengoTimeout bounds one execution of a rule's embedded program, for both the
19+
// `script` rules below and the `metric` formulas beside them.
20+
//
21+
// These are the two checks whose body is arbitrary code, and both usually
22+
// arrive inside a downloaded style package rather than being written by the
23+
// person running Vale. Restricting the imports -- see NewScript -- stops such a
24+
// program reaching the filesystem or the network, but says nothing about how
25+
// long it may take, and `for {}` compiles as readily as anything else. Without
26+
// a deadline that hangs Vale with no output and no error, which in CI looks
27+
// like the tool having crashed rather than a rule misbehaving.
28+
//
29+
// Generous on purpose: a script matches against a single block and a formula
30+
// evaluates once per file, so this sits orders of magnitude above what a
31+
// working rule needs and only a runaway one should ever reach it.
32+
const tengoTimeout = 2 * time.Second
33+
34+
// ruleError reports a rule's runtime failure against the rule itself.
35+
//
36+
// The path alone identifies the file but not which check inside it stopped, and
37+
// a package may define several. The name is what appears in a rule's output and
38+
// in a user's config, so it is the handle they already have for switching the
39+
// thing off.
40+
//
41+
// A deadline is also restated: `context deadline exceeded` is Go's wording for
42+
// an internal mechanism, and a style author reading it has no reason to connect
43+
// it to a rule of theirs that never returns.
44+
func ruleError(name, field, path string, err error, timedOut bool) error {
45+
msg := err.Error()
46+
if timedOut {
47+
msg = fmt.Sprintf("did not finish within %s", tengoTimeout)
48+
}
49+
if name != "" {
50+
msg = name + ": " + msg
51+
}
52+
53+
return core.NewE201FromTarget(msg, field, path)
54+
}
55+
1456
// Script is Tango-based script.
1557
//
1658
// see https://github.com/d5/tengo.
@@ -85,8 +127,12 @@ func (s Script) Run(blk nlp.Block, _ *core.File, _ *core.Config) ([]core.Alert,
85127
return alerts, core.NewE201FromTarget(err.Error(), "script", s.path)
86128
}
87129

88-
if err := compiled.Run(); err != nil {
89-
return alerts, core.NewE201FromTarget(err.Error(), "script", s.path)
130+
ctx, cancel := context.WithTimeout(context.Background(), tengoTimeout)
131+
defer cancel()
132+
133+
if err := compiled.RunContext(ctx); err != nil {
134+
return alerts, ruleError(
135+
s.Name, "script", s.path, err, errors.Is(ctx.Err(), context.DeadlineExceeded))
90136
}
91137

92138
for _, match := range parseMatches(compiled.Get("matches").Array()) {

internal/check/script_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package check
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
"time"
9+
10+
"github.com/d5/tengo/v2"
11+
"github.com/d5/tengo/v2/stdlib"
12+
13+
"github.com/errata-ai/vale/v3/internal/nlp"
14+
)
15+
16+
// compileScript builds a rule's program the way NewScript does, without the
17+
// config plumbing needed to read one off disk.
18+
func compileScript(t *testing.T, src string) *tengo.Compiled {
19+
t.Helper()
20+
21+
program := tengo.NewScript([]byte(src))
22+
program.SetImports(stdlib.GetModuleMap("text", "fmt", "math"))
23+
24+
if err := program.Add("scope", ""); err != nil {
25+
t.Fatal(err)
26+
}
27+
28+
compiled, err := program.Compile()
29+
if err != nil {
30+
t.Fatal(err)
31+
}
32+
33+
return compiled
34+
}
35+
36+
// A script rule is the only check whose body is arbitrary code, and it usually
37+
// arrives inside a downloaded style package. Denying it the `os` module bounds
38+
// what it can reach; this bounds how long it can take. Without the deadline
39+
// this test does not fail, it hangs.
40+
func TestScriptRunStopsAtTheTimeout(t *testing.T) {
41+
s := Script{
42+
compiled: compileScript(t, "matches := []\nfor { }"),
43+
path: "Runaway.yml",
44+
}
45+
46+
start := time.Now()
47+
_, err := s.Run(nlp.Block{Text: "some text to match against"}, nil, nil)
48+
elapsed := time.Since(start)
49+
50+
if err == nil {
51+
t.Fatal("a script that never returns should have been stopped")
52+
}
53+
if elapsed > tengoTimeout*3 {
54+
t.Errorf("took %s to give up on a %s timeout", elapsed, tengoTimeout)
55+
}
56+
}
57+
58+
// A rule that runs away has to say which rule it was: the file may hold
59+
// several checks, and the name is the handle a user has for switching one off.
60+
// The deadline is restated for the same reason -- Go's own wording for it names
61+
// a mechanism the reader has never heard of.
62+
func TestScriptRunTimeoutNamesTheRule(t *testing.T) {
63+
dir := t.TempDir()
64+
path := filepath.Join(dir, "Runaway.yml")
65+
66+
err := os.WriteFile(path,
67+
[]byte("extends: script\nlevel: error\nscript: |\n for { }\n"), 0o600)
68+
if err != nil {
69+
t.Fatal(err)
70+
}
71+
72+
s := Script{compiled: compileScript(t, "matches := []\nfor { }"), path: path}
73+
s.Name = "Runaway.Loop"
74+
75+
_, err = s.Run(nlp.Block{Text: "some text"}, nil, nil)
76+
if err == nil {
77+
t.Fatal("expected an error")
78+
}
79+
80+
for _, want := range []string{"Runaway.Loop", "did not finish within"} {
81+
if !strings.Contains(err.Error(), want) {
82+
t.Errorf("error is missing %q:\n%v", want, err)
83+
}
84+
}
85+
if strings.Contains(err.Error(), "context deadline exceeded") {
86+
t.Errorf("error still reports Go's internal wording:\n%v", err)
87+
}
88+
}
89+
90+
// The deadline must not cost a working rule its result.
91+
func TestScriptRunReturnsMatchesWithinTheTimeout(t *testing.T) {
92+
src := `
93+
text := import("text")
94+
95+
matches := []
96+
idx := text.index(scope, "storage")
97+
if idx >= 0 {
98+
matches = append(matches, {begin: idx, end: idx + 7})
99+
}
100+
`
101+
102+
s := Script{
103+
compiled: compileScript(t, src),
104+
path: "Storage.yml",
105+
}
106+
107+
alerts, err := s.Run(nlp.Block{Text: "the storage layer"}, nil, nil)
108+
if err != nil {
109+
t.Fatal(err)
110+
}
111+
112+
if len(alerts) != 1 {
113+
t.Fatalf("got %d alerts, want 1", len(alerts))
114+
}
115+
if got := alerts[0].Match; got != "storage" {
116+
t.Errorf("matched %q, want %q", got, "storage")
117+
}
118+
}

0 commit comments

Comments
 (0)