Skip to content

Commit 33202f1

Browse files
committed
Turn what outside AI pen testers proved into checks that cost nothing to run
Five vendors tested a PHP application. The weaknesses they proved now have code rules: Dompdf running PHP inside rendered documents, the request path written into a page unescaped, a formula guard that only looks at the first character, uploads accepted on the browser's content type, address checks that miss the 100.64.0.0/10 shared range, and forwarding headers trusted from anywhere. Each was run against about twelve thousand files of other code and reported only real problems. New web checks find a dependency lock file served from the web root and extra path after a PHP script written into the page, both confirmed against real Apache and PHP. The open redirect check adds the path form and stops reporting a redirect to the site's own sign in page, using a new header_matches test in the rule language. Code reasoning names the access mistakes those testers found most, without asking again about code it has already read.
1 parent 38c3c93 commit 33202f1

7 files changed

Lines changed: 477 additions & 9 deletions

File tree

‎app/core/code_reasoning.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,16 +164,26 @@ def mask(text: str) -> str:
164164
Report only these, and only when the code you were given shows it:
165165
object-authz an id or key from the request reaches a read, change or delete \
166166
of a stored object, and nothing checks that the caller owns it, is assigned to \
167-
it, or may otherwise reach it. A role check alone is not enough.
167+
it, or may otherwise reach it. A role check alone is not enough. This \
168+
includes a child id (a question, answer, note, line item or file) never checked \
169+
to belong to the parent object or public token the caller holds, and a search, \
170+
list, autocomplete or export that returns objects the same caller is refused \
171+
when asking for one by id.
168172
function-authz a privileged or state changing action that a lower role can \
169173
reach, because the server checks only that somebody is signed in, or checks \
170-
the wrong role. Hiding a button is not a check.
174+
the wrong role. Hiding a button is not a check. This includes an action \
175+
that spends money or quota (a model call, a mass update, an export of \
176+
everything) open to a read only role.
171177
field-authz individual fields or questions with their own edit or view \
172-
rules are written or returned without applying those rules.
178+
rules are written or returned without applying those rules, or an owner, \
179+
assignee or user id field set from the request to a user the caller could not \
180+
otherwise pick.
173181
csrf a browser reachable state change whose forgery check is \
174182
missing or does nothing.
175183
auth-logic sign in, second factor, session or single sign on logic that \
176-
can be bypassed, replayed, or have its lockout reset.
184+
can be bypassed, replayed, or have its lockout reset; a session or token \
185+
that keeps working after its user is deactivated or loses a role; a one time \
186+
code or public link that still works after the record it opened is finished.
177187
trust-client a value only the server should decide (a price, a score, a \
178188
role, a verdict) taken from the request as is.
179189
model-trust a language model's answer is used to lower, close, waive, \

‎app/core/dast_checks/curated/business-logic-open-redirect.json‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
"/?returnUrl=https://canary-openredirect.example/",
1515
"/?dest=https://canary-openredirect.example/",
1616
"/login?next=https://canary-openredirect.example/",
17-
"/logout?next=https://canary-openredirect.example/"
17+
"/logout?next=https://canary-openredirect.example/",
18+
"/%5Ccanary-openredirect.example/%2f..",
19+
"/%2F%2Fcanary-openredirect.example/%2f.."
1820
],
1921
"probe": [
2022
{
@@ -27,10 +29,10 @@
2729
],
2830
"confirm": {
2931
"control": "A path that cannot exist, so the site's not found answer is known and a real redirect to the foreign host is told apart from it.",
30-
"differential": "status_in(301, 302, 303, 307, 308) and \"canary-openredirect.example\" in header(\"location\")",
32+
"differential": "status_in(301, 302, 303, 307, 308) and header_matches(\"location\", \"^\\\\s*(https?:)?[/\\\\\\\\]{2}canary-openredirect\\\\.example\")",
3133
"retest": 2
3234
},
33-
"reproduce": "curl -sk -i 'https://host/?next=https://canary-openredirect.example/' and check the response is a 30x whose Location header points at canary-openredirect.example. Try the other parameter names (url, redirect, return, returnUrl, dest) and the login and logout paths. A redirect to an outside host means the parameter is an open redirect.",
35+
"reproduce": "curl -sk -i 'https://host/?next=https://canary-openredirect.example/' and check the response is a 30x whose Location header points at canary-openredirect.example. Try the other parameter names (url, redirect, return, returnUrl, dest) and the login and logout paths. A path form also works on some servers: curl -sk -i 'https://host/%5Ccanary-openredirect.example/%2f..' answering with Location: /\\canary-openredirect.example/..., which a browser reads as the outside host. A redirect to an outside host means the parameter is an open redirect.",
3436
"remediate": "Do not redirect to a location taken from the request. Allow only relative paths, or an explicit allow list of hosts, and reject or ignore an absolute URL to another site.",
3537
"references": [],
3638
"mappings": {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"id": "injection-php-path-info-reflected",
3+
"class": "injection",
4+
"safety": "read",
5+
"severity_floor": "medium",
6+
"title": "Extra path after a PHP script is written into the page unescaped",
7+
"summary": "A PHP page accepts extra path after its own name (page.php/anything) and writes it back into the HTML without encoding it, usually through $_SERVER['PHP_SELF'] in a form action or a link. A link with markup in that extra path puts the markup on the page: a meta refresh that sends the visitor to any site, a fake sign in form, or script where the content security policy allows it.",
8+
"needs": [],
9+
"candidate_paths": [
10+
"/index.php/gcrpi%22%3E%3Cgcrpi7q%3E",
11+
"/login.php/gcrpi%22%3E%3Cgcrpi7q%3E",
12+
"/admin.php/gcrpi%22%3E%3Cgcrpi7q%3E",
13+
"/search.php/gcrpi%22%3E%3Cgcrpi7q%3E",
14+
"/contact.php/gcrpi%22%3E%3Cgcrpi7q%3E",
15+
"/register.php/gcrpi%22%3E%3Cgcrpi7q%3E",
16+
"/profile.php/gcrpi%22%3E%3Cgcrpi7q%3E",
17+
"/dashboard.php/gcrpi%22%3E%3Cgcrpi7q%3E",
18+
"/forgot-password.php/gcrpi%22%3E%3Cgcrpi7q%3E",
19+
"/reset-password.php/gcrpi%22%3E%3Cgcrpi7q%3E"
20+
],
21+
"probe": [
22+
{
23+
"request": {
24+
"method": "GET",
25+
"path": "{route}",
26+
"follow": false
27+
}
28+
}
29+
],
30+
"confirm": {
31+
"control": "A path that cannot exist. The marker only ever goes in the probe, so a page that shows it unescaped wrote the request's own path into its markup.",
32+
"differential": "status_in(200) and body_contains('\"><gcrpi7q>') and 'html' in header('content-type')",
33+
"retest": 2
34+
},
35+
"reproduce": "curl -sk \"https://host/index.php/x%22%3E%3Cb%3Etest%3C/b%3E\" and look for the literal text \"><b>test</b> in the page, unescaped (not &quot;&gt;&lt;b&gt;). Try the other script names the rule lists, and any other PHP page on the site. In a browser, a meta refresh in the same place sends the visitor to another site.",
36+
"remediate": "Encode the value before writing it into HTML: htmlspecialchars($_SERVER['PHP_SELF'], ENT_QUOTES, 'UTF-8'). Better, do not use PHP_SELF or REQUEST_URI in markup at all: leave a form's action empty to post back to the same page, or write the script's fixed path. Turning off AcceptPathInfo in Apache also stops the extra path from reaching the script.",
37+
"references": [
38+
"https://owasp.org/www-community/attacks/xss/"
39+
],
40+
"mappings": {
41+
"cwe": "CWE-79",
42+
"owasp": "A03:2021"
43+
},
44+
"rulesets": []
45+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"id": "misconfiguration-dependency-lockfile-served",
3+
"class": "misconfiguration",
4+
"safety": "read",
5+
"severity_floor": "low",
6+
"anonymous": true,
7+
"title": "Dependency manifest or lock file served from the web root",
8+
"summary": "The site serves its composer, npm, yarn, Ruby or Python lock file to anybody. It lists every library and its exact version, which tells an attacker which known vulnerabilities to try first, and it shows that the project folder itself is the web root, so other files beside it may be served too.",
9+
"needs": [],
10+
"candidate_paths": [
11+
"/composer.lock",
12+
"/composer.json",
13+
"/package-lock.json",
14+
"/yarn.lock",
15+
"/Gemfile.lock",
16+
"/Pipfile.lock",
17+
"/poetry.lock"
18+
],
19+
"probe": [
20+
{
21+
"request": {
22+
"method": "GET",
23+
"path": "{route}",
24+
"follow": false
25+
}
26+
}
27+
],
28+
"confirm": {
29+
"control": "A path that cannot exist, so a site that answers every path with the same page is not mistaken for one serving the file.",
30+
"differential": "status_in(200) and body_differs(control) and body_matches(\"(?m)\\\"content-hash\\\"\\\\s*:\\\\s*\\\"[0-9a-f]{32}\\\"|\\\"lockfileVersion\\\"\\\\s*:\\\\s*\\\\d|^# yarn lockfile v1|^GEM\\\\s*$\\\\s+remote:|\\\"_meta\\\"\\\\s*:\\\\s*\\\\{\\\\s*\\\"hash\\\"|^# This file is automatically @generated by Poetry|\\\"require\\\"\\\\s*:\\\\s*\\\\{[^}]*\\\"php\\\"\\\\s*:\")",
31+
"retest": 2
32+
},
33+
"reproduce": "curl -sk 'https://host/composer.lock' | head -20 and look for \"content-hash\" and a \"packages\" list. Try package-lock.json (\"lockfileVersion\"), yarn.lock (\"# yarn lockfile v1\"), Gemfile.lock (\"GEM\" then \"remote:\"), Pipfile.lock (\"_meta\") and poetry.lock. Compare with a path that cannot exist, so a page served for every path is not mistaken for the file.",
34+
"remediate": "Serve the application from a public folder that holds only what browsers need, not from the project root. If that cannot change yet, deny dependency and lock files in the web server (for Apache: <FilesMatch \"^(composer\\.(json|lock)|package(-lock)?\\.json|yarn\\.lock|Gemfile\\.lock|Pipfile\\.lock|poetry\\.lock)$\"> Require all denied </FilesMatch>).",
35+
"references": [
36+
"https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/04-Review_Old_Backup_and_Unreferenced_Files_for_Sensitive_Information"
37+
],
38+
"mappings": {
39+
"cwe": "CWE-538",
40+
"owasp": "A05:2021"
41+
},
42+
"rulesets": []
43+
}

‎app/core/dast_checks/engine.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ def request(self, method: str, path: str, query: dict = None,
112112

113113
_ALLOWED_FUNCS = {"body_differs", "body_contains", "header", "reflected",
114114
"status_in", "differ", "same", "status_of",
115-
"body_matches", "cookie_matches"}
115+
"body_matches", "cookie_matches", "header_matches"}
116116
_ALLOWED_CMP = (ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE,
117117
ast.In, ast.NotIn)
118118

@@ -264,6 +264,10 @@ def status_of(resp):
264264
def body_matches(pattern):
265265
return bool(_rule_regex(pattern).search(body[:MATCH_BODY_CHARS]))
266266

267+
def header_matches(name, pattern):
268+
"""True when the named response header matches the pattern."""
269+
return bool(_rule_regex(pattern).search(headers.get(str(name).lower(), "")[:4000]))
270+
267271
def cookie_matches(pattern):
268272
"""True when any cookie the response set matches, attributes included."""
269273
rx = _rule_regex(pattern)
@@ -279,7 +283,7 @@ def cookie_matches(pattern):
279283
"header": header, "reflected": reflected,
280284
"status_in": status_in, "differ": differ, "same": same,
281285
"status_of": status_of, "body_matches": body_matches,
282-
"cookie_matches": cookie_matches},
286+
"cookie_matches": cookie_matches, "header_matches": header_matches},
283287
}
284288
env.update(saved or {})
285289
return env

0 commit comments

Comments
 (0)