Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8378f4b52c |
@@ -493,10 +493,8 @@ each request against their rules in that order, as "Rule files" in
|
|||||||
[`SPEC.md`](SPEC.md) describes. A file whose name starts with `.`, such as an
|
[`SPEC.md`](SPEC.md) describes. A file whose name starts with `.`, such as an
|
||||||
editor's lock file `.#50-app.rules`, is not a rule file, as a shell's `*.rules`
|
editor's lock file `.#50-app.rules`, is not a rule file, as a shell's `*.rules`
|
||||||
would not match it. A rule is a line of four fields separated by spaces or tabs:
|
would not match it. A rule is a line of four fields separated by spaces or tabs:
|
||||||
an id, a target, an action and a regex, which runs to the end of the line.
|
an id, a target, an action and a regex, which runs to the end of the line. Blank
|
||||||
Spaces and tabs at the end of a line are not part of its regex, so a line with
|
lines and lines that start with `#` are ignored.
|
||||||
only those after its action has no regex, and is not a rule. Blank lines and
|
|
||||||
lines that start with `#` are ignored.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
# id target action regex
|
# id target action regex
|
||||||
@@ -522,20 +520,18 @@ scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
|
|||||||
and takes time linear in the text it reads. It matches anywhere in the target
|
and takes time linear in the text it reads. It matches anywhere in the target
|
||||||
unless anchored with `^` and `$`; `(?i)` at its front makes it ignore case.
|
unless anchored with `^` and `$`; `(?i)` at its front makes it ignore case.
|
||||||
|
|
||||||
A line that is not a rule, a `header:` name with a character no header name can
|
A line that is not a rule, a rule for the `Host` or the `Transfer-Encoding`
|
||||||
have, such as `header:User-Agent:`, a rule for the `Host` or the
|
header, a regex that does not compile or an id used twice stops the start with a
|
||||||
`Transfer-Encoding` header, a regex that does not compile or an id used twice
|
message naming the file and the line, and so does a `SWWAF_RULES_DIR` that does
|
||||||
stops the start with a message naming the file and the line, and so does a
|
not exist. An empty directory is no error, and the log says that it holds no
|
||||||
`SWWAF_RULES_DIR` that does not exist. An empty directory is no error, and the
|
rules. While it runs, `smallwebwaf` watches the directory, and reads the rule
|
||||||
log says that it holds no rules. While it runs, `smallwebwaf` watches the
|
files again once the directory has had no change for 2 seconds after one is
|
||||||
directory, and reads the rule files again once the directory has had no change
|
edited, added or removed, so that a file saved in place, appended to or copied
|
||||||
for 2 seconds after one is edited, added or removed, so that a file saved in
|
in with `scp` is read only once whole, unless its writing stops for longer. It
|
||||||
place, appended to or copied in with `scp` is read only once whole, unless its
|
also reads them 2 seconds after it starts watching, so that an edit saved while
|
||||||
writing stops for longer. It also reads them 2 seconds after it starts watching,
|
it started is not missed. If they then hold one of those errors, the rules stay
|
||||||
so that an edit saved while it started is not missed. If they then hold one of
|
as they were, the earlier version of the edited file included, the log names the
|
||||||
those errors, the rules stay as they were, the earlier version of the edited
|
file and the line, and the files are read again after the next change.
|
||||||
file included, the log names the file and the line, and the files are read again
|
|
||||||
after the next change.
|
|
||||||
|
|
||||||
The image ships one rule file, `share/rules.d/00-default.rules` here: rules that
|
The image ships one rule file, `share/rules.d/00-default.rules` here: rules that
|
||||||
ban probes no real visitor sends, for secrets, version control directories,
|
ban probes no real visitor sends, for secrets, version control directories,
|
||||||
|
|||||||
@@ -752,22 +752,6 @@ func parseCountries(value string) ([]string, error) {
|
|||||||
const headerNameChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +
|
const headerNameChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +
|
||||||
"0123456789!#$%&'*+-.^_`|~"
|
"0123456789!#$%&'*+-.^_`|~"
|
||||||
|
|
||||||
// IsHeaderName reports whether name can be a header name: one or more of
|
|
||||||
// the characters RFC 9110 allows in one.
|
|
||||||
func IsHeaderName(name string) bool {
|
|
||||||
if name == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, char := range name {
|
|
||||||
if !strings.ContainsRune(headerNameChars, char) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseHeaderNames reads a comma-separated list of header names in either
|
// parseHeaderNames reads a comma-separated list of header names in either
|
||||||
// case, and returns them in lower case. Host and Transfer-Encoding are
|
// case, and returns them in lower case. Host and Transfer-Encoding are
|
||||||
// refused: Go's HTTP server takes them out of the request's headers.
|
// refused: Go's HTTP server takes them out of the request's headers.
|
||||||
@@ -780,8 +764,10 @@ func parseHeaderNames(value string) ([]string, error) {
|
|||||||
headers := make([]string, 0, len(items))
|
headers := make([]string, 0, len(items))
|
||||||
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if !IsHeaderName(item) {
|
for _, char := range item {
|
||||||
return nil, fmt.Errorf("%q %w", item, errNotHeaderName)
|
if !strings.ContainsRune(headerNameChars, char) {
|
||||||
|
return nil, fmt.Errorf("%q %w", item, errNotHeaderName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header := strings.ToLower(item)
|
header := strings.ToLower(item)
|
||||||
|
|||||||
+3
-13
@@ -21,8 +21,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The actions a rule takes when it matches.
|
// The actions a rule takes when it matches.
|
||||||
@@ -67,8 +65,6 @@ var (
|
|||||||
errNotID = errors.New("is not an id of letters, digits, - and _")
|
errNotID = errors.New("is not an id of letters, digits, - and _")
|
||||||
errNotTarget = errors.New(
|
errNotTarget = errors.New(
|
||||||
"is not path, query, uri, method, host, user_agent, referer or header:<Name>")
|
"is not path, query, uri, method, host, user_agent, referer or header:<Name>")
|
||||||
errNotHeaderName = errors.New(
|
|
||||||
"has a character after header: that no header name can have")
|
|
||||||
errHeaderTakenOut = errors.New(
|
errHeaderTakenOut = errors.New(
|
||||||
"names a header that Go's HTTP server takes out of every request, " +
|
"names a header that Go's HTTP server takes out of every request, " +
|
||||||
"so a rule never sees it")
|
"so a rule never sees it")
|
||||||
@@ -113,8 +109,7 @@ type Files struct {
|
|||||||
|
|
||||||
// Load reads the rules of every *.rules file in Dir, in the order of the
|
// Load reads the rules of every *.rules file in Dir, in the order of the
|
||||||
// files' names, unless Enabled is false. A Dir that cannot be read is an
|
// files' names, unless Enabled is false. A Dir that cannot be read is an
|
||||||
// error, and so is a line that is not a rule, a header name with a
|
// error, and so is a line that is not a rule, a rule for the Host or the
|
||||||
// character no header name can have, a rule for the Host or the
|
|
||||||
// Transfer-Encoding header, which Go's HTTP server takes out of every
|
// Transfer-Encoding header, which Go's HTTP server takes out of every
|
||||||
// request, a regex that does not compile and an id used twice, each named
|
// request, a regex that does not compile and an id used twice, each named
|
||||||
// with its file and line.
|
// with its file and line.
|
||||||
@@ -313,11 +308,9 @@ func readFile(path string, rules []Rule, places map[string]string) ([]Rule, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parse reads a line of a rule file. It returns false for a blank line
|
// parse reads a line of a rule file. It returns false for a blank line
|
||||||
// and for a comment, a line that starts with #. Spaces and tabs at the
|
// and for a comment, a line that starts with #.
|
||||||
// end of the line are not part of its regex, so a line with only those
|
|
||||||
// after its action has no regex, and is not a rule.
|
|
||||||
func parse(line string) (Rule, bool, error) {
|
func parse(line string) (Rule, bool, error) {
|
||||||
line = strings.Trim(line, " \t")
|
line = strings.TrimLeft(line, " \t")
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
return Rule{}, false, nil
|
return Rule{}, false, nil
|
||||||
}
|
}
|
||||||
@@ -328,15 +321,12 @@ func parse(line string) (Rule, bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rule := Rule{ID: fields[1], Target: fields[2], Action: fields[3]}
|
rule := Rule{ID: fields[1], Target: fields[2], Action: fields[3]}
|
||||||
headerName, isHeader := strings.CutPrefix(rule.Target, headerTarget)
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case !idChars.MatchString(rule.ID):
|
case !idChars.MatchString(rule.ID):
|
||||||
return Rule{}, false, fmt.Errorf("the id %q %w", rule.ID, errNotID)
|
return Rule{}, false, fmt.Errorf("the id %q %w", rule.ID, errNotID)
|
||||||
case !isTarget(rule.Target):
|
case !isTarget(rule.Target):
|
||||||
return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotTarget)
|
return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotTarget)
|
||||||
case isHeader && !config.IsHeaderName(headerName):
|
|
||||||
return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotHeaderName)
|
|
||||||
case strings.EqualFold(rule.Target, headerTarget+"Host"):
|
case strings.EqualFold(rule.Target, headerTarget+"Host"):
|
||||||
return Rule{}, false, fmt.Errorf(
|
return Rule{}, false, fmt.Errorf(
|
||||||
"the target %q %w; the request's host is the target host",
|
"the target %q %w; the request's host is the target host",
|
||||||
|
|||||||
@@ -145,14 +145,6 @@ after path log ^/
|
|||||||
wantMatched(t, files, get(t, "/"), "ban")
|
wantMatched(t, files, get(t, "/"), "ban")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSpacesAndTabsEndingALineAreNotPartOfItsRegex(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
files := load(t, ruleFiles{testFile: "env-file path block ^/\\.env$ \t \n"})
|
|
||||||
|
|
||||||
wantMatched(t, files, get(t, "/.env"), "env-file")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilesReadInNameOrderThenLineOrder(t *testing.T) {
|
func TestFilesReadInNameOrderThenLineOrder(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -208,12 +200,6 @@ func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) {
|
|||||||
"is not a rule: an id, a target, an action and a regex, " +
|
"is not a rule: an id, a target, an action and a regex, " +
|
||||||
"separated by spaces or tabs",
|
"separated by spaces or tabs",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// Else its regex would be a space, found in nearly every user agent.
|
|
||||||
"a regex of only spaces and tabs", "scanner user_agent ban\t \n", 1,
|
|
||||||
"is not a rule: an id, a target, an action and a regex, " +
|
|
||||||
"separated by spaces or tabs",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"an id of other characters", "# ids\n\nenv.file path ban ^/\n", 3,
|
"an id of other characters", "# ids\n\nenv.file path ban ^/\n", 3,
|
||||||
`the id "env.file" is not an id of letters, digits, - and _`,
|
`the id "env.file" is not an id of letters, digits, - and _`,
|
||||||
@@ -228,21 +214,6 @@ func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) {
|
|||||||
`the target "header:" is not path, query, uri, method, host, ` +
|
`the target "header:" is not path, query, uri, method, host, ` +
|
||||||
"user_agent, referer or header:<Name>",
|
"user_agent, referer or header:<Name>",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"a header name written with its colon", "sqlmap header:User-Agent: ban sqlmap\n", 1,
|
|
||||||
`the target "header:User-Agent:" has a character after header: ` +
|
|
||||||
"that no header name can have",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a header name with a semicolon", "accept header:Accept;q log ^$\n", 1,
|
|
||||||
`the target "header:Accept;q" has a character after header: ` +
|
|
||||||
"that no header name can have",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a header name with brackets", "x-header header:X(y) log ^$\n", 1,
|
|
||||||
`the target "header:X(y)" has a character after header: ` +
|
|
||||||
"that no header name can have",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"the Host header", "host-header header:host block ^$\n", 1,
|
"the Host header", "host-header header:host block ^$\n", 1,
|
||||||
`the target "header:host" names a header that Go's HTTP server ` +
|
`the target "header:host" names a header that Go's HTTP server ` +
|
||||||
|
|||||||
Reference in New Issue
Block a user