Repository: corazawaf/coraza
Pull request #565 (2023-01-11 03:44:51)

Support evaluation of rules in multiple phases
For coraza-proxy-wasm, we have been discussing evaluation escape because Envoy will send request headers upstream before waiting for request body. As we execute phase 1 when headers are available, phase 2 rules will not have been run by then. Unfortunately, many rules in CRS are set to phase 2 but match against both phase 1 and phase 2 variables, for example the log4shell rule. Malicious headers can be sent upstream without checking them.

As we do have a good idea of when variables are ready for many of them, it is possible for us to infer which phases the rule should run. Connectors should generally know when their server/proxy will send headers before body or not, so this opts in as a build tag for now. It could be a runtime flag in the future if we find a use case for it, e.g. a server that can have both modes based on some configuration. Or maybe we find this behavior to be good anyways and we remove the flag, who knows.

The two problematic variables are `Args` and `ArgsNames` - they mutate between phase 1 and 2. I had initially thought we could just evaluate them twice but found a single rule that relies on idempotency

https://github.com/coreruleset/coreruleset/blob/6eeea4243fdf3ddd0b1a0263bdee63a4f57ca55e/rules/REQUEST-921-PROTOCOL-ATTACK.conf#L442

Though it means in concept, a variable can only be matched once as even custom rules could rely on idempotency, so these variables are still evaluated at phase 2. It means is still possible for e.g. `Args` that match during headers to escape - while a general split of rules in CRS would be a huge undertaking, perhaps just changing e.g. `ARGS` to `ARGS_GET|ARGS_POST` would still be reasonable.

The rules that get executed in multiple phases are in https://gist.github.com/anuraaga/91223f9ae7f7b7251b86b0411fadb9b4. 944150, log4shell is there. And in general we can expect all these rules to allow bypass of headers currently with Envoy.

There are a couple of expectations that provide motivation for this change

- It would likely be overopinionated to require connectors to block headers before receiving a body. Proxies may not have a lifecycle that follows this and become unable to use Coraza / CRS if we require it
- There is already a phase 1 and 2. If evaluation of phase 1 doesn't actually mean phase 1 information is safe because phase 2 rules are required, there is no meaning in a phase 1 and we'd expect to only have a request/response phase. The intent of the phases must be to allow headers to be considered safe at phase 1 it seems

Also note that initially I considered splitting the rules themselves at the seclang level to not need library code change - but I realized this would need IDs assigned to the split rules and FTW would be difficult to make work in that world. As this change ended up relatively small, I think the library change behind a build tag should be OK

@fzipi @jcchavezs @jptosso @M4tteoP will be good to discuss this. Though hopefully we can be relatively optimistic on merging given the default behavior without a build tag is unchanged and it can all be reverted if needed

codecov-commenter: # [Codecov](https://codecov.io/gh/corazawaf/coraza/pull/565?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf) Report
Base: **78.72**% // Head: **77.43**% // Decreases project coverage by **`-1.29%`** :warning:
> Coverage data is based on head [(`cd63df7`)](https://codecov.io/gh/corazawaf/coraza/pull/565?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf) compared to base [(`703d296`)](https://codecov.io/gh/corazawaf/coraza/commit/703d29668893989dc83983756f6296aa37acc06d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf).
> Patch coverage: 41.05% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           v3/dev     #565      +/-   ##
==========================================
- Coverage   78.72%   77.43%   -1.30%     
==========================================
  Files         145      145              
  Lines        6994     7232     +238     
==========================================
+ Hits         5506     5600      +94     
- Misses       1257     1400     +143     
- Partials      231      232       +1     
```

| Flag | Coverage Δ | |
|---|---|---|
| default | `72.71% <35.36%> (-1.35%)` | :arrow_down: |
| examples | `26.64% <10.97%> (-0.63%)` | :arrow_down: |
| ftw | `55.84% <34.14%> (-0.84%)` | :arrow_down: |
| tinygo | `70.65% <33.73%> (-1.44%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/corazawaf/coraza/pull/565?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf) | Coverage Δ | |
|---|---|---|
| [types/phase.go](https://codecov.io/gh/corazawaf/coraza/pull/565?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf#diff-dHlwZXMvcGhhc2UuZ28=) | `47.05% <0.00%> (ø)` | |
| [internal/corazawaf/rule.go](https://codecov.io/gh/corazawaf/coraza/pull/565?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf#diff-aW50ZXJuYWwvY29yYXphd2FmL3J1bGUuZ28=) | `67.88% <38.19%> (-26.80%)` | :arrow_down: |
| [internal/corazawaf/rulegroup.go](https://codecov.io/gh/corazawaf/coraza/pull/565?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf#diff-aW50ZXJuYWwvY29yYXphd2FmL3J1bGVncm91cC5nbw==) | `94.73% <100.00%> (+0.55%)` | :arrow_up: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/corazawaf/coraza/pull/565?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf).

jcchavezs: Ping @airween @dune73
jptosso: Looks good to me if it helps the envoy connector, but please before merging create a readme section with all available build constraints
anuraaga: > In this list, I'm surprised by the presence of rules with just one variable

Oops I must have extracted the list incorrectly, thanks for checking it. Anyways glad to have log4shell handled :)
anuraaga: Added comments / README
anuraaga: Filed https://github.com/coreruleset/coreruleset/issues/3087 for CRS to consider removing the mutating HTTP variables
dune73: Thanks for the invitation tot his conversation. I am not sure I get this.

Is the idea to kind of ignore phases, but to process individual targets of rule(s) the moment they are available? This implies an execution with an outer loop over targets and an inner loop over rules, while ModSec executes outer loop over rules and inner loop over targets. So I expect the change to be quite fundamental and not clear if this will work or what it means in terms of rules and security. Certainly an interesting experiment.

Separate evaluation of ARGS_GET and ARGS_POST over ARGS in CRS can certainly be discussed if it adds value.

From a general perspective, I would not simply accept the envoy behavior as a god-given mode of operation. Envoy forwards parts of a request without seeing the entire request, for performance reasons obviously. I agree there are rules where the request headers are only inspected for certain patterns the moment we have the ARGS as well. That means CRS has not yet given the green light for a request, yet Envoy takes things for granted and rushes ahead with the request. Consequently request headers land in the backend's logviewer before they have been inspected throughly by CRS. This is asking for trouble. If you find a way around it, then fine. But I do not see it as a CRS problem. It's risky behavior by Envoy and it should at least be configurable.

If you need to talk to Envoy, then I suggest creating an attack scenario based on the Phase 2 REQUEST_HEADER rules that can ideally detect XSS before it hits a logviewer - as outlined above.

airween: > Ping @airween 

oh, thanks :).

I haven't put much effort into discovering the syntax, so I can't imagine how this would work in practice?

I mean how could you tell the engine that you want to inspect the given rule both in phase 1 and 2?
```
    phase:1,\
    phase:2,\
```
?
Or would you like to use that some kind of implicit way?


anuraaga: > Is the idea to kind of ignore phases, but to process individual targets of rule(s) the moment they are available? 

It supplements the phase defined in the rule with earlier phases as needed. I'd say it's less ignoring and more respecting phases - currently phase 1 has no real meaning, most rules haven't run by then. So if not executing more rules, like log4shell, during phase 1, it's a false sense of security. If really doubling-down on the idea that the request must block completely before sending anything upstream/downstream, it seems that phase 1 and 3 should be outright removed.

> From a general perspective, I would not simply accept the envoy behavior as a god-given mode of operation.

It's not a god-given mode of operation, it's just _a_ mode of operation ;) CRS is a part in a toolkit, so I think where it isn't prohibitive (this PR seems to demonstrate it isn't), it should be applicable to as many scenarios as possible. And again, sending headers after phase 1 processing seems totally sane and not risky, that seems to be what phase 1 should mean. The fact that phase 1 currently has no real meaning is the problem.

> If you need to talk to Envoy, then I suggest creating an attack scenario based on the Phase 2 REQUEST_HEADER rules that can ideally detect XSS before it hits a logviewer - as outlined above.

Not sure I quite follow, but with evaluation of XSS in both phase 1 and 2 as in this PR, it wouldn't hit any logviewer.

> I mean how could you tell the engine that you want to inspect the given rule both in phase 1 and 2?

This PR is doing an implicit way, I haven't given much thought on what syntax could be like for explicit but that seems reasonable to me. It would also be good to concretely define the phase that each variable is evaluated, I don't think that's formally defined yet so I just decided it here

https://github.com/corazawaf/coraza/pull/565/files#diff-32bf0985de09d747ed6a6d8400fa4e02e61e15b9cb67b3da4623c050bf1306ccR525
M4tteoP: Hi @dune73, @airween, thanks for joining!

> executes outer loop over rules and inner loop over targets

> how this would work in practice?

While adding rules to the engine, for each rule, a list of inferred phases is populated according to a fixed correspondence between targets and at which phase we expect that it is available.
As a matter of example: With a rule like `SecRule REQUEST_HEADERS|REQUEST_BODY [...] phase:2`, Coraza with multiphaseEvaluation enabled, would populate the `inferredPhases` with: `[phase:1, phase:2]`. It happens because `REQUEST_HEADERS` is expected to be ready for evaluation already at `phase:1`.
An outer loop over rules and an inner loop over targets are still in place, what changes is that the inner loop will not evaluate all the targets, but only the one related to each phase. `REQUEST_HEADERS` evaluation will be anticipated at `phase:1` and skipped during `phase:2`. Here comes the value of splitting `ARGS` (and `ARGS_NAMES`) into `ARGS_GET|ARGS_POST`. Each arg would be evaluated just once, at the earliest possible phase.

That being said, I personally see this experimental feature to work with Coraza, rather than expect to see it ported into ModSec.

> It's risky behavior by Envoy and it should at least be configurable.

I agree that a configurable option would be an added feature Envoy side. Still, streaming an HTTP request sending the header before the body is valid HTTP. If there is a chance to make CRS more compatible with transparent proxies, it would be great to explore it. Providing splitted variables to be executed at the earliest convenience, could make CRS more tolerant with other proxies out there and not tight to apache/Nginx lifecycle.

theseion: Do I understand correctly, that the split into `ARGS_GET|ARGS_POST` would work because `ARGS_POST` necessarily is a phase 2 variable? If that is correct, I still don't understand how this should solve anything, unless you would run the rule twice, once in phase 1 with only the phase 1 variables and once in phase 2 with only the phase 2 variables. And in case of a match in phase 1 you would not be allowed to run the rule in phase 2 again to avoid doubling the anomaly score.
theseion: I think this is a great idea. One question though: if you already know that `ARGS` is `ARGS_GET|ARGS_POST`, why does it need to be explicit in the rule? The engine could have a "breakdown" phase, where variables are broken down into their parts (as `ARGS`) and then you'd have exactly what you want without CRS having to know about Coraza or Envoy or "early evaluation" at all.
anuraaga: > The engine could have a "breakdown" phase, where variables are broken down into their parts

Yeah I think that can actually be implemented. For CRS I think it's mostly on whether it seems like having these variables that mutate across the phases seems helpful or confusing. Before that, it's better to understand what CRS's stance is on the phases themselves - if CRS doesn't know about early evaluation at all, then I suggest that CRS is fundamentally broken, as phase 1 is not usable. A stance where phase 1 and 3 are not used at all could make that coherent, or alternatively explicitly stating two types of CRS evaluation - skip phase 1/3 and block on 2/4, or early evaluate variables. Those seem like the only models that are safe with the current CRS.
theseion: Another thing: to avoid incrementing the anomaly score more than once for the same rule, it would probably make sense to skip evaluation of a rule in phase 2 if it already matched in phase 1 (if that is even necessary).
dune73: Everything gets a bit blurry for me here. A puzzling element is the fact that you say you want to keep the phases in CRS rules and follow them, but then anticipate some phase 2 rules into phase 1.

I think we need a detailed writeup of an example (attack) request and what would happen step by step.

Then @anuraaga states phase 1 is not usable. If you mean fundamentally not usable regardless of the webserver, then this is just plainly wrong. If you mean in the Envoy context then I do not get it, since Envoy seems to evaluate phase 1 before forwarding to the backend. So if there is a log4j attack in the UA, then Envoy will catch that via CRS in phase 1 just fine and you can block at the end of phase 1 via the CRSv4 early-blocking feature. I do not understand why it's deemed unusable.

Above, @anuraaga also explained `And again, sending headers after phase 1 processing seems totally sane and not risky, that seems to be what phase 1 should mean. The fact that phase 1 currently has no real meaning is the problem.`

This statement gives me the feeling you have a very different perception of ModSec processing phases. Why would it have no meaning?

Totally sane and not risky? Take a POST request with an SQLi in the payload. Envoy looks at the headers in phase 1, all happy, forwards the request announcing a given Content-Length to the backend. Request body arrives, CRS detects SQLi, request is blocked, client receives a 403, what do you send to the backend? How do you expect the backend to react? How will the backend learn that the request has been aborted? All under the assumption of backends that are not aware of Envoy and its behavior.
anuraaga: > So if there is a log4j attack in the UA, then Envoy will catch that via CRS in phase 1 just fine and you can block at the end of phase 1 via the CRSv4 early-blocking feature

Not before this PR / conceptual addition of multi phase evaluation. Because log4shell is only checked in phase 2

https://github.com/coreruleset/coreruleset/blob/6eeea4243fdf3ddd0b1a0263bdee63a4f57ca55e/rules/REQUEST-944-APPLICATION-ATTACK-JAVA.conf#L212
dune73: OK. Point taken. :)

Then please take an SQLi in the filename, meant to be detected by 942101.
anuraaga: I believe that is referring to your point that headers will be sent, but the request then terminated during body processing by connection or stream close. This is different from not sending the request upstream at all, but it doesn't seem obviously worse - there seems to be a tradeoff of handling of non-blocked requests (faster bytes to upstream) vs blocked requests (don't pollute upstream logs with terminated requests) that could be left up to the user. But I can understand if the recommendation is the latter, don't send anything upstream for blocked requests - in that case it still goes back to the point where phase 1 does not have any real meaning because the expectation would be to always run to phase 2 before sending anything upstream. 
dune73: Again, meaning depends on the perspective.

But if you have a backend, that can handle not received request body when they are expected, then there is certainly some speed gains by forwarding request headers before the WAF is sure the entire request is clean. So if it's configurable on Envoy, then cool.

However, the way you want to handle this in the WAF with ARGS and the phases is really tricky. I get the feeling it might end up in a big mess.

On the positive side, there are plenty of unit tests. So if they can be made to work in such a mode, then you should be fine.
anuraaga: > However, the way you want to handle this in the WAF with ARGS and the phases is really tricky. I get the feeling it might end up in a big mess.

If bringing to mind more general concepts, I think it is really about immutable vs mutable. Generally things aren't tricky when dealing with immutable data because the lifecycle is so easy to understand. ARGS / names are the only two http variables that are mutable across phases and contribute to feeling like it may end up as a mess. That's the only reason I propose also deprecating those - if all the http variables are immutable I think this sort of multiphase evaluation becomes relatively easy to reason about, doesn't it?
dune73: You have a point with the mutable variables. `ARGS` is there for convenience reasons, but its mutable character sure is an oddity.
RedXanadu: It seems like this is due to a fundamental difference in behaviour and design.

- ModSecurity wants to deny requests *as early as possible*, to hopefully save the CPU cycles and RAM that would be required for body processing in phase 2 (and also phase 4). So, phase 1 is **invaluable**. (Example - an IP subnet block: instant deny, don't waste resources on body inspection.)
- Envoy wants to opportunistically stream request headers *as soon as possible*. This breaks the ModSecurity axiom that the final deny/allow decision can _always_ made at the end of phase 2, and only then will a request be proxied upstream. CRS rules are built on that logic. (It's absolutely not the case that "end of phase 1" = "all data that has been received so far is fully inspected and good to go").

In fairness, if time to first byte is **crucial** for an application then one needs to bear in mind that putting a WAF in front **will** introduce latency/delays. I think it's reasonable to expect a WAF proxy to at least have a 'security mode' option for "wait until request headers have been fully inspected before allowing a request through".

I think that makes sense… It's an interesting problem to think about.
M4tteoP: > So, phase 1 is invaluable. (Example - an IP subnet block: instant deny, don't waste resources on body inspection.)

I think that this is an important shared point. A WAF engine that wants to deny requests as early as possible seems to me pretty compatible with the willingness to inspect the data at the earliest possible phase. If we already have enough information at Phase 1 to deny a request, waiting for phase 2 is not the best.

From a CRS point of view, it would basically mean making the "early blocking" feature more effective by doing all the possible inspections as early as possible

anuraaga: So it sounds like there is some interest in doing more in phase 1, but it should be driven by the engine rather than the ruleset which is fine.

For this PR that's adding the feature experimentally behind a flag, are we comfortable to merge @jcchavezs @M4tteoP?
dune73: Yes, full reasoning at https://github.com/coreruleset/coreruleset/issues/3087#issuecomment-1384641131

Patch:
diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml
index 94bd3e9..589bafa 100644
--- a/.github/workflows/regression.yml
+++ b/.github/workflows/regression.yml
@@ -45,6 +45,11 @@ jobs:
         with:
           files: build/coverage-ftw.txt
           flags: ftw
+      - uses: codecov/codecov-action@v2
+        if: ${{ matrix.go-version == '1.19.x' }}
+        with:
+          files: build/coverage-ftw-multiphase.txt
+          flags: ftw-multiphase
       - uses: codecov/codecov-action@v2
         if: ${{ matrix.go-version == '1.19.x' }}
         with:
diff --git a/README.md b/README.md
index 7e4e07b..225c822 100644
--- a/README.md
+++ b/README.md
@@ -107,6 +107,16 @@ func main() {
 ```
 [Examples/http-server](./examples/http-server/) provides an example to practice with Coraza.
 
+### Build tags
+
+Go build tags can tweak certain functionality at compile-time. These are for advanced use cases only and do not
+have compatibility guarantees across minor versions - use with care.
+
+- coraza.disabled_operators.* - excludes the specified operator from compilation. Particularly useful if overriding
+the operator with `operators.Register` to reduce binary size / startup overhead.
+- `coraza.rule.multiphase_valuation` - enables evaluation of rule variables in the phases that they are ready, not
+only the phase the rule is defined for.
+
 ## Tools
 
 * [Go FTW](https://github.com/coreruleset/go-ftw): Rule testing engine
diff --git a/http/middleware.go b/http/middleware.go
index 23e83a3..0a401c0 100644
--- a/http/middleware.go
+++ b/http/middleware.go
@@ -8,6 +8,7 @@
 package http
 
 import (
+	"fmt"
 	"io"
 	"net/http"
 	"strconv"
@@ -58,16 +59,21 @@ func processRequest(tx types.Transaction, req *http.Request) (*types.Interruptio
 		// body inspection, otherwise we just let the request follow its
 		// regular flow.
 		if req.Body != nil && req.Body != http.NoBody {
-			_, err := io.Copy(tx.RequestBodyWriter(), req.Body)
+			it, _, err := tx.ReadRequestBodyFrom(req.Body)
 			if err != nil {
-				return nil, err
+				return nil, fmt.Errorf("failed to append request body: %s", err.Error())
 			}
 
-			reader, err := tx.RequestBodyReader()
+			if it != nil {
+				return it, nil
+			}
+
+			rbr, err := tx.RequestBodyReader()
 			if err != nil {
-				return nil, err
+				return nil, fmt.Errorf("failed to get the request body: %s", err.Error())
 			}
-			reader = io.MultiReader(reader, req.Body)
+
+			body := io.MultiReader(rbr, req.Body)
 			// req.Body is transparently reinizialied with a new io.ReadCloser.
 			// The http handler will be able to read it.
 			// Prior to Go 1.19 NopCloser does not implement WriterTo if the reader implements it.
@@ -76,24 +82,22 @@ func processRequest(tx types.Transaction, req *http.Request) (*types.Interruptio
 			// This avoid errors like "failed to process request: malformed chunked encoding" when
 			// using io.Copy.
 			// In Go 1.19 we just do `req.Body = io.NopCloser(reader)`
-			if rwt, ok := reader.(io.WriterTo); ok {
+			if rwt, ok := body.(io.WriterTo); ok {
 				req.Body = struct {
 					io.Reader
 					io.WriterTo
 					io.Closer
-				}{reader, rwt, req.Body}
+				}{body, rwt, req.Body}
 			} else {
 				req.Body = struct {
 					io.Reader
 					io.Closer
-				}{reader, req.Body}
+				}{body, req.Body}
 			}
 		}
-
-		return tx.ProcessRequestBody()
 	}
 
-	return nil, nil
+	return tx.ProcessRequestBody()
 }
 
 func WrapHandler(waf coraza.WAF, l Logger, h http.Handler) http.Handler {
diff --git a/internal/corazawaf/body_buffer.go b/internal/corazawaf/body_buffer.go
index 1a476a9..4068a71 100644
--- a/internal/corazawaf/body_buffer.go
+++ b/internal/corazawaf/body_buffer.go
@@ -16,13 +16,33 @@ import (
 // It will handle memory usage for buffering and processing
 // It implements io.Copy(bodyBuffer, someReader) by inherit io.Writer
 type BodyBuffer struct {
-	io.Writer
 	options types.BodyBufferOptions
 	buffer  *bytes.Buffer
 	writer  *os.File
 	length  int64
 }
 
+var (
+	_ io.WriterTo = (*BodyBuffer)(nil)
+	_ io.Writer   = (*BodyBuffer)(nil)
+)
+
+func (br *BodyBuffer) WriteTo(w io.Writer) (int64, error) {
+	if br.writer == nil {
+		return br.buffer.WriteTo(w)
+	}
+
+	b := make([]byte, br.length)
+
+	n, err := br.writer.Read(b)
+	if err != nil {
+		return 0, err
+	}
+
+	n, err = w.Write(b[:n])
+	return int64(n), err
+}
+
 // Write appends data to the body buffer by chunks
 // You may dump io.Readers using io.Copy(br, reader)
 func (br *BodyBuffer) Write(data []byte) (n int, err error) {
diff --git a/internal/corazawaf/rule.go b/internal/corazawaf/rule.go
index 1909348..31df73f 100644
--- a/internal/corazawaf/rule.go
+++ b/internal/corazawaf/rule.go
@@ -147,6 +147,10 @@ type Rule struct {
 	Disruptive bool
 
 	HasChain bool
+
+	// inferredPhases is the inferred phases the rule is relevant for
+	// based on the processed variables.
+	inferredPhases [types.PhaseLogging + 1]bool
 }
 
 func (r *Rule) ParentID() int {
@@ -160,11 +164,11 @@ func (r *Rule) Status() int {
 // Evaluate will evaluate the current rule for the indicated transaction
 // If the operator matches, actions will be evaluated, and it will return
 // the matched variables, keys and values (MatchData)
-func (r *Rule) Evaluate(tx rules.TransactionState, cache map[transformationKey]*transformationValue) []types.MatchData {
-	return r.doEvaluate(tx.(*Transaction), cache)
+func (r *Rule) Evaluate(phase types.RulePhase, tx rules.TransactionState, cache map[transformationKey]*transformationValue) []types.MatchData {
+	return r.doEvaluate(phase, tx.(*Transaction), cache)
 }
 
-func (r *Rule) doEvaluate(tx *Transaction, cache map[transformationKey]*transformationValue) []types.MatchData {
+func (r *Rule) doEvaluate(phase types.RulePhase, tx *Transaction, cache map[transformationKey]*transformationValue) []types.MatchData {
 	if r.Capture {
 		tx.Capture = true
 	}
@@ -196,6 +200,16 @@ func (r *Rule) doEvaluate(tx *Transaction, cache map[transformationKey]*transfor
 	} else {
 		ecol := tx.ruleRemoveTargetByID[r.ID_]
 		for _, v := range r.variables {
+			// TODO(anuraaga): Support skipping variables based on phase for rule chains too.
+			if multiphaseEvaluation && !r.HasChain && r.ParentID_ == 0 {
+				min := minPhase(v.Variable)
+				// When multiphase evaluation is enabled, any variable is evaluated at its
+				// earliest possible phase, so we skip it if the rule refers to other phases
+				// to avoid double-evaluation.
+				if min != types.PhaseUnknown && min != phase {
+					continue
+				}
+			}
 			var values []types.MatchData
 			for _, c := range ecol {
 				if c.Variable == v.Variable {
@@ -265,7 +279,7 @@ func (r *Rule) doEvaluate(tx *Transaction, cache map[transformationKey]*transfor
 		// we only run the chains for the parent rule
 		for nr := r.Chain; nr != nil; {
 			tx.WAF.Logger.Debug("[%s] [%d] Evaluating rule chain for %d", tx.id, rid, r.ID_)
-			matchedChainValues := nr.Evaluate(tx, cache)
+			matchedChainValues := nr.Evaluate(phase, tx, cache)
 			if len(matchedChainValues) == 0 {
 				return matchedChainValues
 			}
@@ -503,3 +517,236 @@ func NewRule() *Rule {
 		},
 	}
 }
+
+// minPhase returns the earliest phase a variable may be populated.
+// NOTE: variables.Args and variables.ArgsNames should ideally be evaluated
+// both in phase 1 and 2, but rules can set state in the transaction, e.g. a
+// counter, which prevents evaluating any variable multiple times.
+func minPhase(v variables.RuleVariable) types.RulePhase {
+	switch v {
+	case variables.ResponseContentType:
+		return types.PhaseResponseHeaders
+	case variables.UniqueID:
+		return types.PhaseRequestHeaders
+	case variables.ArgsCombinedSize:
+		// Size changes between phase 1 and 2 so evaluate both times
+		return types.PhaseRequestHeaders
+	case variables.AuthType:
+		// Not populated by Coraza, but should generally be in headers
+		return types.PhaseRequestHeaders
+	case variables.FilesCombinedSize:
+		return types.PhaseRequestBody
+	case variables.FullRequest:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.FullRequestLength:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.InboundDataError:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.MatchedVar:
+		// MatchedVar is only for logging, not evaluation
+		return types.PhaseUnknown
+	case variables.MatchedVarName:
+		// MatchedVar is only for logging, not evaluation
+		return types.PhaseUnknown
+	case variables.MultipartBoundaryQuoted:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	// MultipartBoundaryWhitespace kept for compatibility
+	case variables.MultipartBoundaryWhitespace:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.MultipartCrlfLfLines:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.MultipartDataAfter:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.MultipartDataBefore:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.MultipartFileLimitExceeded:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.MultipartHeaderFolding:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.MultipartInvalidHeaderFolding:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.MultipartInvalidPart:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.MultipartInvalidQuoting:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.MultipartLfLine:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.MultipartMissingSemicolon:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.MultipartStrictError:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.MultipartUnmatchedBoundary:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.OutboundDataError:
+		return types.PhaseResponseBody
+	case variables.PathInfo:
+		// Not populated by Coraza
+		return types.PhaseRequestHeaders
+	case variables.QueryString:
+		return types.PhaseRequestHeaders
+	case variables.RemoteAddr:
+		return types.PhaseRequestHeaders
+	case variables.RemoteHost:
+		// Not implemented
+		return types.PhaseRequestHeaders
+	case variables.RemotePort:
+		return types.PhaseRequestHeaders
+	case variables.ReqbodyError:
+		return types.PhaseRequestBody
+	case variables.ReqbodyErrorMsg:
+		return types.PhaseRequestBody
+	case variables.ReqbodyProcessorError:
+		return types.PhaseRequestBody
+	case variables.ReqbodyProcessorErrorMsg:
+		return types.PhaseRequestBody
+	case variables.ReqbodyProcessor:
+		// Configuration of Coraza itself, though shouldn't be used in phases
+		return types.PhaseUnknown
+	case variables.RequestBasename:
+		return types.PhaseRequestHeaders
+	case variables.RequestBody:
+		return types.PhaseRequestBody
+	case variables.RequestBodyLength:
+		return types.PhaseRequestBody
+	case variables.RequestFilename:
+		return types.PhaseRequestHeaders
+	case variables.RequestLine:
+		return types.PhaseRequestHeaders
+	case variables.RequestMethod:
+		return types.PhaseRequestHeaders
+	case variables.RequestProtocol:
+		return types.PhaseRequestHeaders
+	case variables.RequestURI:
+		return types.PhaseRequestHeaders
+	case variables.RequestURIRaw:
+		return types.PhaseRequestHeaders
+	case variables.ResponseBody:
+		return types.PhaseResponseBody
+	case variables.ResponseContentLength:
+		return types.PhaseResponseBody
+	case variables.ResponseProtocol:
+		return types.PhaseResponseHeaders
+	case variables.ResponseStatus:
+		return types.PhaseResponseHeaders
+	case variables.ServerAddr:
+		// Configuration of the server itself
+		return types.PhaseRequestHeaders
+	case variables.ServerName:
+		// Configuration of the server itself
+		return types.PhaseRequestHeaders
+	case variables.ServerPort:
+		// Configuration of the server itself
+		return types.PhaseRequestHeaders
+	case variables.Sessionid:
+		// Not populated by Coraza
+		return types.PhaseRequestHeaders
+	case variables.HighestSeverity:
+		// Result of matching, not used in phaes
+		return types.PhaseUnknown
+	case variables.StatusLine:
+		return types.PhaseResponseHeaders
+	case variables.InboundErrorData:
+		return types.PhaseRequestBody
+	case variables.Duration:
+		// If used in matching, would need to be defined for multiple inferredPhases to make sense
+		return types.PhaseUnknown
+	case variables.ResponseHeadersNames:
+		return types.PhaseResponseHeaders
+	case variables.RequestHeadersNames:
+		return types.PhaseRequestHeaders
+	case variables.Userid:
+		// Not populated by Coraza
+		return types.PhaseRequestHeaders
+	case variables.Args:
+		// Updated between headers and body
+		return types.PhaseRequestBody
+	case variables.ArgsGet:
+		return types.PhaseRequestHeaders
+	case variables.ArgsPost:
+		return types.PhaseRequestBody
+	case variables.ArgsPath:
+		return types.PhaseRequestHeaders
+	case variables.FilesSizes:
+		return types.PhaseRequestBody
+	case variables.FilesNames:
+		return types.PhaseRequestBody
+	case variables.FilesTmpContent:
+		// Not populated by Coraza
+		return types.PhaseRequestBody
+	case variables.MultipartFilename:
+		return types.PhaseRequestBody
+	case variables.MultipartName:
+		return types.PhaseRequestBody
+	case variables.MatchedVarsNames:
+		// Result of execution, not used in inferredPhases
+		return types.PhaseUnknown
+	case variables.MatchedVars:
+		// Result of execution, not used in inferredPhases
+		return types.PhaseUnknown
+	case variables.Files:
+		return types.PhaseRequestBody
+	case variables.RequestCookies:
+		return types.PhaseRequestHeaders
+	case variables.RequestHeaders:
+		return types.PhaseRequestHeaders
+	case variables.ResponseHeaders:
+		return types.PhaseResponseHeaders
+	case variables.Geo:
+		// Not populated by Coraza
+		return types.PhaseRequestHeaders
+	case variables.RequestCookiesNames:
+		return types.PhaseRequestHeaders
+	case variables.FilesTmpNames:
+		return types.PhaseRequestBody
+	case variables.ArgsNames:
+		// Updated between headers and body
+		return types.PhaseRequestBody
+	case variables.ArgsGetNames:
+		return types.PhaseRequestHeaders
+	case variables.ArgsPostNames:
+		return types.PhaseRequestBody
+	case variables.TX:
+		return types.PhaseUnknown
+	case variables.Rule:
+		// Shouldn't be used in phases
+		return types.PhaseUnknown
+	case variables.JSON:
+		return types.PhaseRequestBody
+	case variables.Env:
+		return types.PhaseRequestHeaders
+	case variables.IP:
+		// Not populated by Coraza
+		return types.PhaseRequestHeaders
+	case variables.UrlencodedError:
+		return types.PhaseRequestHeaders
+	case variables.ResponseArgs:
+		return types.PhaseResponseBody
+	case variables.ResponseXML:
+		return types.PhaseResponseBody
+	case variables.RequestXML:
+		return types.PhaseRequestBody
+	case variables.XML:
+		return types.PhaseRequestBody
+	case variables.MultipartPartHeaders:
+		return types.PhaseRequestBody
+	}
+
+	return types.PhaseUnknown
+}
diff --git a/internal/corazawaf/rule_option_default.go b/internal/corazawaf/rule_option_default.go
new file mode 100644
index 0000000..de0f1c4
--- /dev/null
+++ b/internal/corazawaf/rule_option_default.go
@@ -0,0 +1,11 @@
+// Copyright 2023 Juan Pablo Tosso and the OWASP Coraza contributors
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build !coraza.rule.multiphase_evaluation
+
+package corazawaf
+
+// multiphaseEvaluation indicates whether we should evaluate rules that match against variables
+// ready in multiple phases in each of those phases. CRS sets the phase for many rules to the
+// maximum phase of all variables, which means that earlier variables have evaluation deferred.
+const multiphaseEvaluation = false
diff --git a/internal/corazawaf/rule_option_multiphase.go b/internal/corazawaf/rule_option_multiphase.go
new file mode 100644
index 0000000..df1b1c9
--- /dev/null
+++ b/internal/corazawaf/rule_option_multiphase.go
@@ -0,0 +1,8 @@
+// Copyright 2023 Juan Pablo Tosso and the OWASP Coraza contributors
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build coraza.rule.multiphase_evaluation
+
+package corazawaf
+
+const multiphaseEvaluation = true
diff --git a/internal/corazawaf/rulegroup.go b/internal/corazawaf/rulegroup.go
index 62cdda4..919fb49 100644
--- a/internal/corazawaf/rulegroup.go
+++ b/internal/corazawaf/rulegroup.go
@@ -31,6 +31,19 @@ func (rg *RuleGroup) Add(rule *Rule) error {
 	if rg.FindByID(rule.ID_) != nil && rule.ID_ != 0 {
 		return fmt.Errorf("there is a another rule with id %d", rule.ID_)
 	}
+
+	numInferred := 0
+	rule.inferredPhases[rule.Phase_] = true
+	for _, v := range rule.variables {
+		min := minPhase(v.Variable)
+		if min != 0 {
+			// We infer the earliest phase a variable used by the rule may be evaluated for use when
+			// multiphase evaluation is enabled
+			rule.inferredPhases[min] = true
+			numInferred++
+		}
+	}
+
 	rg.rules = append(rg.rules, rule)
 	return nil
 }
@@ -110,8 +123,11 @@ RulesLoop:
 			break RulesLoop
 		}
 		// Rules with phase 0 will always run
-		if r.Phase_ != phase && r.Phase_ != 0 {
-			continue
+		if r.Phase_ != 0 && r.Phase_ != phase {
+			// Execute the rule in inferred phases too if multiphase evaluation is enabled
+			if !multiphaseEvaluation || !r.inferredPhases[phase] {
+				continue
+			}
 		}
 
 		// we skip the rule in case it's in the excluded list
@@ -141,7 +157,7 @@ RulesLoop:
 		tx.variables.matchedVars.Reset()
 		tx.variables.matchedVarsNames.Reset()
 
-		r.Evaluate(tx, transformationCache)
+		r.Evaluate(phase, tx, transformationCache)
 		tx.Capture = false // we reset captures
 		usedRules++
 	}
diff --git a/internal/corazawaf/transaction.go b/internal/corazawaf/transaction.go
index 9aaba57..e122e2c 100644
--- a/internal/corazawaf/transaction.go
+++ b/internal/corazawaf/transaction.go
@@ -66,7 +66,7 @@ type Transaction struct {
 	LastPhase types.RulePhase
 
 	// Handles request body buffers
-	RequestBodyBuffer *BodyBuffer
+	requestBodyBuffer *BodyBuffer
 
 	// Handles response body buffers
 	ResponseBodyBuffer *BodyBuffer
@@ -327,7 +327,7 @@ func (tx *Transaction) ResponseBodyReader() (io.Reader, error) {
 }
 
 func (tx *Transaction) RequestBodyReader() (io.Reader, error) {
-	return tx.RequestBodyBuffer.Reader()
+	return tx.requestBodyBuffer.Reader()
 }
 
 // AddRequestHeader Adds a request header
@@ -447,14 +447,25 @@ func (tx *Transaction) ParseRequestReader(data io.Reader) (*types.Interruption,
 		ct, _, _ = strings.Cut(ctcol[0], ";")
 	}
 	for scanner.Scan() {
-		if _, err := tx.RequestBodyBuffer.Write(scanner.Bytes()); err != nil {
+		it, _, err := tx.WriteRequestBody(scanner.Bytes())
+		if err != nil {
 			return nil, fmt.Errorf("cannot write to request body to buffer: %s", err.Error())
 		}
+
+		if it != nil {
+			return it, nil
+		}
+
 		// urlencoded cannot end with CRLF
 		if ct != "application/x-www-form-urlencoded" {
-			if _, err := tx.RequestBodyBuffer.Write([]byte{'\r', '\n'}); err != nil {
+			it, _, err := tx.WriteRequestBody([]byte{'\r', '\n'})
+			if err != nil {
 				return nil, fmt.Errorf("cannot write to request body to buffer: %s", err.Error())
 			}
+
+			if it != nil {
+				return it, nil
+			}
 		}
 	}
 	return tx.ProcessRequestBody()
@@ -760,8 +771,134 @@ func (tx *Transaction) ProcessRequestHeaders() *types.Interruption {
 	return tx.interruption
 }
 
-func (tx *Transaction) RequestBodyWriter() io.Writer {
-	return tx.RequestBodyBuffer
+func setAndReturnBodyLimitInterruption(tx *Transaction) (*types.Interruption, int, error) {
+	tx.variables.inboundErrorData.Set("1")
+	tx.interruption = &types.Interruption{
+		Status: 403,
+		Action: "deny",
+	}
+	return tx.interruption, 0, nil
+}
+
+// WriteRequestBody writes bytes from a slice of bytes into the request body,
+// it returns an interuption if the writing bytes go beyond the request body limit.
+// It won't copy the bytes if the body access isn't accesible.
+func (tx *Transaction) WriteRequestBody(b []byte) (*types.Interruption, int, error) {
+	if tx.RuleEngine == types.RuleEngineOff {
+		return nil, 0, nil
+	}
+
+	if !tx.RequestBodyAccess {
+		return nil, 0, nil
+	}
+
+	if tx.RequestBodyLimit == tx.requestBodyBuffer.length {
+		// tx.RequestBodyLimit will never be zero so if this happened, we have an
+		// interruption for sure.
+		if tx.WAF.RequestBodyLimitAction == types.RequestBodyLimitActionReject {
+			return tx.interruption, 0, nil
+		}
+
+		if tx.WAF.RequestBodyLimitAction == types.RequestBodyLimitActionProcessPartial {
+			return nil, 0, nil
+		}
+	}
+
+	var (
+		writingBytes          = int64(len(b))
+		runProcessRequestBody = false
+	)
+
+	if tx.requestBodyBuffer.length+writingBytes >= tx.RequestBodyLimit {
+		if tx.WAF.RequestBodyLimitAction == types.RequestBodyLimitActionReject {
+			// We interrupt this transaction in case RequestBodyLimitAction is Reject
+			return setAndReturnBodyLimitInterruption(tx)
+		}
+
+		if tx.WAF.RequestBodyLimitAction == types.RequestBodyLimitActionProcessPartial {
+			writingBytes = tx.RequestBodyLimit - tx.requestBodyBuffer.length
+			runProcessRequestBody = true
+		}
+	}
+
+	w, err := tx.requestBodyBuffer.Write(b[:writingBytes])
+	if err != nil {
+		return nil, 0, err
+	}
+
+	if runProcessRequestBody {
+		_, err = tx.ProcessRequestBody()
+	}
+	return tx.interruption, int(w), err
+}
+
+// ByteLenger returns the length in bytes of a data stream.
+type ByteLenger interface {
+	Len() int
+}
+
+// ReadRequestBodyFrom writes bytes from a reader into the request body
+// it returns an interuption if the writing bytes go beyond the request body limit.
+// It won't read the reader if the body access isn't accesible.
+func (tx *Transaction) ReadRequestBodyFrom(r io.Reader) (*types.Interruption, int, error) {
+	if tx.RuleEngine == types.RuleEngineOff {
+		return nil, 0, nil
+	}
+
+	if !tx.RequestBodyAccess {
+		return nil, 0, nil
+	}
+
+	if tx.RequestBodyLimit == tx.requestBodyBuffer.length {
+		if tx.WAF.RequestBodyLimitAction == types.RequestBodyLimitActionReject {
+			return tx.interruption, 0, nil
+		}
+
+		if tx.WAF.RequestBodyLimitAction == types.RequestBodyLimitActionProcessPartial {
+			return nil, 0, nil
+		}
+	}
+
+	var (
+		writingBytes          int64
+		runProcessRequestBody = false
+	)
+	if l, ok := r.(ByteLenger); ok {
+		writingBytes = int64(l.Len())
+		if tx.requestBodyBuffer.length+writingBytes >= tx.RequestBodyLimit {
+			if tx.WAF.RequestBodyLimitAction == types.RequestBodyLimitActionReject {
+				return setAndReturnBodyLimitInterruption(tx)
+			}
+
+			if tx.WAF.RequestBodyLimitAction == types.RequestBodyLimitActionProcessPartial {
+				writingBytes = tx.RequestBodyLimit - tx.requestBodyBuffer.length
+				runProcessRequestBody = true
+			}
+		}
+	} else {
+		writingBytes = tx.RequestBodyLimit - tx.requestBodyBuffer.length
+	}
+
+	w, err := io.CopyN(tx.requestBodyBuffer, r, writingBytes)
+	if err != nil && err != io.EOF {
+		return nil, int(w), err
+	}
+
+	if tx.requestBodyBuffer.length == tx.RequestBodyLimit {
+		if tx.WAF.RequestBodyLimitAction == types.RequestBodyLimitActionReject {
+			return setAndReturnBodyLimitInterruption(tx)
+		}
+
+		if tx.WAF.RequestBodyLimitAction == types.RequestBodyLimitActionProcessPartial {
+			runProcessRequestBody = true
+		}
+	}
+
+	err = nil
+	if runProcessRequestBody {
+		_, err = tx.ProcessRequestBody()
+	}
+	return tx.interruption, int(w), err
 }
 
 // ProcessRequestBody Performs the request body (if any)
@@ -788,7 +925,7 @@ func (tx *Transaction) ProcessRequestBody() (*types.Interruption, error) {
 	}
 
 	// we won't process empty request bodies or disabled RequestBodyAccess
-	if !tx.RequestBodyAccess || tx.RequestBodyBuffer.Size() == 0 {
+	if !tx.RequestBodyAccess || tx.requestBodyBuffer.length == 0 {
 		tx.WAF.Rules.Eval(types.PhaseRequestBody, tx)
 		return tx.interruption, nil
 	}
@@ -797,30 +934,11 @@ func (tx *Transaction) ProcessRequestBody() (*types.Interruption, error) {
 		mime = m[0]
 	}
 
-	reader, err := tx.RequestBodyBuffer.Reader()
+	reader, err := tx.requestBodyBuffer.Reader()
 	if err != nil {
 		return nil, err
 	}
 
-	// Chunked requests will always be written to a temporary file
-	if tx.RequestBodyBuffer.Size() >= tx.RequestBodyLimit {
-		tx.variables.inboundErrorData.Set("1")
-		if tx.WAF.RequestBodyLimitAction == types.RequestBodyLimitActionReject {
-			// We interrupt this transaction in case RequestBodyLimitAction is Reject
-			tx.interruption = &types.Interruption{
-				Status: 403,
-				Action: "deny",
-			}
-			return tx.interruption, nil
-		}
-
-		if tx.WAF.RequestBodyLimitAction == types.RequestBodyLimitActionProcessPartial {
-			tx.variables.inboundErrorData.Set("1")
-			// we limit our reader to tx.RequestBodyLimit bytes
-			reader = io.LimitReader(reader, tx.RequestBodyLimit)
-		}
-	}
-
 	rbp := tx.variables.reqbodyProcessor.String()
 
 	// Default variables.ReqbodyProcessor values
@@ -1127,7 +1245,7 @@ func (tx *Transaction) Close() error {
 	defer tx.WAF.txPool.Put(tx)
 	tx.variables.reset()
 	var errs []error
-	if err := tx.RequestBodyBuffer.Reset(); err != nil {
+	if err := tx.requestBodyBuffer.Reset(); err != nil {
 		errs = append(errs, err)
 	}
 	if err := tx.ResponseBodyBuffer.Reset(); err != nil {
diff --git a/internal/corazawaf/waf.go b/internal/corazawaf/waf.go
index 54531ca..ad16893 100644
--- a/internal/corazawaf/waf.go
+++ b/internal/corazawaf/waf.go
@@ -174,8 +174,8 @@ func (w *WAF) newTransactionWithID(id string) *Transaction {
 
 	// Always non-nil if buffers / collections were already initialized so we don't do any of them
 	// based on the presence of RequestBodyBuffer.
-	if tx.RequestBodyBuffer == nil {
-		tx.RequestBodyBuffer = NewBodyBuffer(types.BodyBufferOptions{
+	if tx.requestBodyBuffer == nil {
+		tx.requestBodyBuffer = NewBodyBuffer(types.BodyBufferOptions{
 			TmpPath:     w.TmpDir,
 			MemoryLimit: w.RequestBodyInMemoryLimit,
 		})
diff --git a/magefile.go b/magefile.go
index eabf81c..5648d04 100644
--- a/magefile.go
+++ b/magefile.go
@@ -74,6 +74,10 @@ func Test() error {
 	if err := sh.RunV("go", "test", "./testing/coreruleset"); err != nil {
 		return err
 	}
+	// Execute FTW tests with multiphase evaluation enabled as well
+	if err := sh.RunV("go", "test", "-tags=coraza.rule.multiphase_evaluation", "./testing/coreruleset"); err != nil {
+		return err
+	}
 
 	return nil
 }
@@ -92,6 +96,10 @@ func Coverage() error {
 	if err := sh.RunV("go", "test", "-coverprofile=build/coverage-ftw.txt", "-covermode=atomic", "-coverpkg=./...", "./testing/coreruleset"); err != nil {
 		return err
 	}
+	// Execute FTW tests with multiphase evaluation enabled as well
+	if err := sh.RunV("go", "test", "-coverprofile=build/coverage-ftw-multiphase.txt", "-covermode=atomic", "-coverpkg=./...", "-tags=coraza.rule.multiphase_evaluation", "./testing/coreruleset"); err != nil {
+		return err
+	}
 	// This is not actually running tests with tinygo, but with the tag that includes its code so we can calculate coverage
 	// for it.
 	if err := sh.RunV("go", "test", "-race", "-tags=tinygo", "-coverprofile=build/coverage-tinygo.txt", "-covermode=atomic", "-coverpkg=./...", "./..."); err != nil {
diff --git a/types/phase.go b/types/phase.go
index 1dee024..00aec24 100644
--- a/types/phase.go
+++ b/types/phase.go
@@ -12,6 +12,8 @@ import (
 type RulePhase int
 
 const (
+	// PhaseUnknown represents a phase unrecognized by Coraza
+	PhaseUnknown RulePhase = 0
 	// PhaseRequestHeaders will process once the request headers are received
 	PhaseRequestHeaders RulePhase = 1
 	// PhaseRequestBody will process once the request body is received
@@ -43,7 +45,7 @@ func ParseRulePhase(phase string) (RulePhase, error) {
 		i, _ = strconv.Atoi(phase)
 	}
 	if i > 5 || i < 1 {
-		return 0, fmt.Errorf("invalid phase %s", phase)
+		return PhaseUnknown, fmt.Errorf("invalid phase %s", phase)
 	}
 	return RulePhase(i), nil
 }
diff --git a/types/transaction.go b/types/transaction.go
index 62f2919..04068dc 100644
--- a/types/transaction.go
+++ b/types/transaction.go
@@ -63,12 +63,8 @@ type Transaction interface {
 	// note: Remember to check for a possible intervention.
 	ProcessRequestHeaders() *Interruption
 
-	// RequestBodyWriter returns a io.Writer for writing the request body to.
-	// Contents will be buffered until the transaction is closed.
-	RequestBodyWriter() io.Writer
-
 	// RequestBodyReader returns a reader for content that has been written by
-	// RequestBodyWriter. This can be useful for buffering the request body
+	// request body buffer. This can be useful for buffering the request body
 	// within the Transaction while also passing it further in an HTTP framework.
 	RequestBodyReader() (io.Reader, error)
 
@@ -88,6 +84,22 @@ type Transaction interface {
 	// Remember to check for a possible intervention.
 	ProcessRequestBody() (*Interruption, error)
 
+	// WriteRequestBody attempts to write data into the body up to the buffer limit and
+	// returns an interruption if the body is bigger than the limit and the action is to
+	// reject. This is specially convenient to resolve an interruption before copying
+	// the body into the request body buffer.
+	//
+	// It returns the corresponding interruption, the number of bytes written an error if any.
+	WriteRequestBody(b []byte) (*Interruption, int, error)
+
+	// ReadRequestBodyFrom attempts to write data into the body up to the buffer limit and
+	// returns an interruption if the body is bigger than the limit and the action is to
+	// reject. This is specially convenient to resolve an interruption before copying
+	// the body into the request body buffer.
+	//
+	// It returns the corresponding interruption, the number of bytes written an error if any.
+	ReadRequestBodyFrom(io.Reader) (*Interruption, int, error)
+
 	// AddResponseHeader Adds a response header variable
 	//
 	// With this method it is possible to feed Coraza with a response header.
@@ -163,6 +175,9 @@ type Transaction interface {
 	// MatchedRules returns the rules that have matched the requests with associated information.
 	MatchedRules() []MatchedRule
 
+	// ID returns the transaction ID.
+	ID() string
+
 	// Closer closes the transaction and releases any resources associated with it such as request/response bodies.
 	io.Closer
 }


Test patch:
diff --git a/http/middleware_test.go b/http/middleware_test.go
index bafe8f8..e8db056 100644
--- a/http/middleware_test.go
+++ b/http/middleware_test.go
@@ -83,7 +83,7 @@ func TestProcessRequestMultipart(t *testing.T) {
 	}
 
 	if err := tx.Close(); err != nil {
-		t.Errorf("failed to close the transaction: %s", err.Error())
+		t.Fatal(err)
 	}
 }
 
@@ -289,16 +289,16 @@ func TestHttpServer(t *testing.T) {
 		t.Run(name, func(t *testing.T) {
 			conf := coraza.NewWAFConfig().
 				WithDirectives(`
-		# This is a comment
-		SecDebugLogLevel 5
-		SecRequestBodyAccess On
-		SecResponseBodyAccess On
-		SecResponseBodyMimeType text/plain
-		SecRule ARGS:id "@eq 0" "id:1, phase:1,deny, status:403,msg:'Invalid id',log,auditlog"
-		SecRule REQUEST_BODY "@contains eval" "id:100, phase:2,deny, status:403,msg:'Invalid request body',log,auditlog"
-		SecRule RESPONSE_HEADERS:Foo "@pm bar" "id:199,phase:3,deny,t:lowercase,deny, status:401,msg:'Invalid response header',log,auditlog"
-		SecRule RESPONSE_BODY "@contains password" "id:200, phase:4,deny, status:403,msg:'Invalid response body',log,auditlog"
-	`).WithErrorCallback(errLogger(t)).WithDebugLogger(&debugLogger{t: t})
+	# This is a comment
+	SecDebugLogLevel 5
+	SecRequestBodyAccess On
+	SecResponseBodyAccess On
+	SecResponseBodyMimeType text/plain
+	SecRule ARGS:id "@eq 0" "id:1, phase:1,deny, status:403,msg:'Invalid id',log,auditlog"
+	SecRule REQUEST_BODY "@contains eval" "id:100, phase:2,deny, status:403,msg:'Invalid request body',log,auditlog"
+	SecRule RESPONSE_HEADERS:Foo "@pm bar" "id:199,phase:3,deny,t:lowercase,deny, status:401,msg:'Invalid response header',log,auditlog"
+	SecRule RESPONSE_BODY "@contains password" "id:200, phase:4,deny, status:403,msg:'Invalid response body',log,auditlog"
+`).WithErrorCallback(errLogger(t)).WithDebugLogger(&debugLogger{t: t})
 			if l := tCase.reqBodyLimit; l > 0 {
 				conf = conf.WithRequestBodyAccess(coraza.NewRequestBodyConfig().WithLimit(l).WithInMemoryLimit(l))
 			}
@@ -306,7 +306,7 @@ func TestHttpServer(t *testing.T) {
 			if err != nil {
 				t.Fatal(err)
 			}
-			runAgainstWaf(t, tCase, waf)
+			runAgainstWAF(t, tCase, waf)
 		})
 	}
 }
@@ -349,12 +349,12 @@ func TestHttpServerWithRuleEngineOff(t *testing.T) {
 			if err != nil {
 				t.Fatal(err)
 			}
-			runAgainstWaf(t, tCase, waf)
+			runAgainstWAF(t, tCase, waf)
 		})
 	}
 }
 
-func runAgainstWaf(t *testing.T, tCase httpTest, waf coraza.WAF) {
+func runAgainstWAF(t *testing.T, tCase httpTest, waf coraza.WAF) {
 	t.Helper()
 	serverErrC := make(chan error, 1)
 	defer close(serverErrC)
diff --git a/internal/corazawaf/transaction_test.go b/internal/corazawaf/transaction_test.go
index 94c7d28..a085918 100644
--- a/internal/corazawaf/transaction_test.go
+++ b/internal/corazawaf/transaction_test.go
@@ -5,6 +5,7 @@ package corazawaf
 
 import (
 	"fmt"
+	"io"
 	"regexp"
 	"runtime/debug"
 	"strconv"
@@ -137,61 +138,213 @@ func TestTxResponse(t *testing.T) {
 	*/
 }
 
-func TestRequestBody(t *testing.T) {
+var requestBodyWriters = map[string]func(tx *Transaction, body string) (*types.Interruption, int, error){
+	"WriteRequestBody": func(tx *Transaction, body string) (*types.Interruption, int, error) {
+		return tx.WriteRequestBody([]byte(body))
+	},
+	"ReadRequestBodyFromKnownLen": func(tx *Transaction, body string) (*types.Interruption, int, error) {
+		return tx.ReadRequestBodyFrom(strings.NewReader(body))
+	},
+	"ReadRequestBodyFromUnknownLen": func(tx *Transaction, body string) (*types.Interruption, int, error) {
+		return tx.ReadRequestBodyFrom(struct{ io.Reader }{
+			strings.NewReader(body),
+		})
+	},
+}
+
+func TestWriteRequestBody(t *testing.T) {
+	const (
+		urlencodedBody    = "some=result&second=data"
+		urlencodedBodyLen = len(urlencodedBody)
+	)
+
 	testCases := []struct {
 		name                   string
-		requestBodyLimit       int64
+		requestBodyLimit       int
 		requestBodyLimitAction types.RequestBodyLimitAction
 		shouldInterrupt        bool
 	}{
 		{
-			name:                   "default",
-			requestBodyLimit:       200,
-			requestBodyLimitAction: types.RequestBodyLimitActionReject,
+			name:                   "LimitNotReached",
+			requestBodyLimit:       urlencodedBodyLen + 2,
+			requestBodyLimitAction: types.RequestBodyLimitAction(-1),
 		},
 		{
-			name:                   "limit rejects",
-			requestBodyLimit:       11,
+			name:                   "LimitReachedAndRejects",
+			requestBodyLimit:       urlencodedBodyLen - 3,
 			requestBodyLimitAction: types.RequestBodyLimitActionReject,
 			shouldInterrupt:        true,
 		},
 		{
-			name:                   "limit partial processing",
-			requestBodyLimit:       11,
+			name:                   "LimitReachedAndPartialProcessing",
+			requestBodyLimit:       urlencodedBodyLen - 3,
 			requestBodyLimitAction: types.RequestBodyLimitActionProcessPartial,
 		},
 	}
 
+	urlencodedBodyLenThird := urlencodedBodyLen / 3
+	bodyChunks := map[string][]string{
+		"BodyInOneShot":     {urlencodedBody},
+		"BodyInThreeChunks": {urlencodedBody[0:urlencodedBodyLenThird], urlencodedBody[urlencodedBodyLenThird : 2*urlencodedBodyLenThird], urlencodedBody[2*urlencodedBodyLenThird:]},
+	}
+
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
-			urlencoded := "some=result&second=data"
-			// xml := "<test><content>test</content></test>"
-			tx := NewWAF().NewTransaction()
-			tx.RequestBodyAccess = true
-			tx.RequestBodyLimit = testCase.requestBodyLimit
-			tx.WAF.RequestBodyLimitAction = testCase.requestBodyLimitAction
-
-			tx.AddRequestHeader("content-type", "application/x-www-form-urlencoded")
-			if _, err := tx.RequestBodyBuffer.Write([]byte(urlencoded)); err != nil {
-				t.Errorf("Failed to write body buffer: %s", err.Error())
-			}
-			tx.ProcessRequestHeaders()
-			if _, err := tx.ProcessRequestBody(); err != nil {
-				t.Errorf("Failed to process request body: %s", err.Error())
+			for name, writeRequestBody := range requestBodyWriters {
+				t.Run(name, func(t *testing.T) {
+					for name, chunks := range bodyChunks {
+						t.Run(name, func(t *testing.T) {
+							waf := NewWAF()
+							waf.RuleEngine = types.RuleEngineOn
+							waf.RequestBodyAccess = true
+							waf.RequestBodyLimit = int64(testCase.requestBodyLimit)
+							waf.RequestBodyInMemoryLimit = int64(testCase.requestBodyLimit)
+							waf.RequestBodyLimitAction = testCase.requestBodyLimitAction
+
+							tx := waf.NewTransaction()
+							tx.AddRequestHeader("content-type", "application/x-www-form-urlencoded")
+
+							it := tx.ProcessRequestHeaders()
+							if it != nil {
+								t.Fatal("Unexpected interruption on headers")
+							}
+
+							var err error
+
+							for _, c := range chunks {
+								if it, _, err = writeRequestBody(tx, c); err != nil {
+									t.Errorf("Failed to write body buffer: %s", err.Error())
+								}
+							}
+
+							if testCase.shouldInterrupt {
+								if it == nil {
+									t.Fatal("Expected interruption, got nil")
+								}
+							} else {
+								it, err := tx.ProcessRequestBody()
+								if err != nil {
+									t.Fatal(err)
+								}
+
+								if it != nil {
+									t.Fatalf("Unexpected interruption")
+								}
+
+								val := tx.variables.argsPost.Get("some")
+								if len(val) != 1 || val[0] != "result" {
+									t.Errorf("Failed to set urlencoded POST data with arguments: \"%s\"", strings.Join(val, "\", \""))
+								}
+							}
+
+							_ = tx.Close()
+						})
+					}
+
+				})
 			}
 
-			if testCase.shouldInterrupt {
-				if tx.interruption == nil {
-					t.Error("expected interruption")
-				}
-			} else {
-				val := tx.variables.argsPost.Get("some")
-				if len(val) != 1 || val[0] != "result" {
-					t.Error("Failed to set url encoded post data")
-				}
+		})
+	}
+}
+
+func TestWriteRequestBodyOnLimitReached(t *testing.T) {
+	testCases := map[string]struct {
+		requestBodyLimitAction  types.RequestBodyLimitAction
+		preexistingInterruption *types.Interruption
+	}{
+		"reject": {
+			requestBodyLimitAction: types.RequestBodyLimitActionReject,
+			preexistingInterruption: &types.Interruption{
+				RuleID: 123,
+			},
+		},
+		"partial processing": {
+			requestBodyLimitAction: types.RequestBodyLimitActionProcessPartial,
+		},
+	}
+
+	for tName, tCase := range testCases {
+		waf := NewWAF()
+		waf.RuleEngine = types.RuleEngineOn
+		waf.RequestBodyAccess = true
+		waf.RequestBodyLimit = 2
+		waf.RequestBodyInMemoryLimit = 2
+		waf.RequestBodyLimitAction = tCase.requestBodyLimitAction
+
+		t.Run(tName, func(t *testing.T) {
+			for wName, writer := range requestBodyWriters {
+				t.Run(wName, func(t *testing.T) {
+					tx := waf.NewTransaction()
+					_, err := tx.requestBodyBuffer.Write([]byte("ab"))
+					if err != nil {
+						t.Fatalf("unexpected error when writing to body buffer directly: %s", err.Error())
+					}
+					tx.interruption = tCase.preexistingInterruption
+
+					it, n, err := writer(tx, "c")
+					if err != nil {
+						t.Fatalf("unexpected error: %s", err.Error())
+					}
+
+					if it != tCase.preexistingInterruption {
+						t.Fatalf("unexpected interruption")
+					}
+
+					if n != 0 {
+						t.Fatalf("unexpected number of bytes written")
+					}
+
+					_ = tx.Close()
+				})
 			}
+		})
+	}
+}
+
+func TestWriteRequestBodyIsNopWhenBodyIsNotAccesible(t *testing.T) {
+	testCases := []struct {
+		ruleEngine        types.RuleEngineStatus
+		requestBodyAccess bool
+	}{
+		{
+			ruleEngine: types.RuleEngineOff,
+		},
+		{
+			ruleEngine:        types.RuleEngineOn,
+			requestBodyAccess: false,
+		},
+	}
 
-			_ = tx.Close()
+	for _, tCase := range testCases {
+		t.Run(fmt.Sprintf(
+			"ruleEngine = %s and requestBodyAccess = %t",
+			tCase.ruleEngine.String(),
+			tCase.requestBodyAccess,
+		), func(t *testing.T) {
+			waf := NewWAF()
+			waf.RuleEngine = tCase.ruleEngine
+			waf.RequestBodyAccess = tCase.requestBodyAccess
+
+			for wName, writer := range requestBodyWriters {
+				t.Run(wName, func(t *testing.T) {
+					tx := waf.NewTransaction()
+					it, n, err := writer(tx, "abc")
+					if err != nil {
+						t.Fatalf("unexpected error: %s", err.Error())
+					}
+
+					if it != nil {
+						t.Fatalf("unexpected interruption")
+					}
+
+					if n != 0 {
+						t.Fatalf("unexpected number of bytes written")
+					}
+
+					_ = tx.Close()
+				})
+			}
 		})
 	}
 }
@@ -408,7 +561,7 @@ func TestRequestBodyProcessingAlgorithm(t *testing.T) {
 	tx.ForceRequestBodyVariable = true
 	tx.AddRequestHeader("content-type", "text/plain")
 	tx.AddRequestHeader("content-length", "7")
-	if _, err := tx.RequestBodyBuffer.Write([]byte("test123")); err != nil {
+	if _, err := tx.requestBodyBuffer.Write([]byte("test123")); err != nil {
 		t.Error("Failed to write request body buffer")
 	}
 	if _, err := tx.ProcessRequestBody(); err != nil {
@@ -563,7 +716,7 @@ func TestTxReqBodyForce(t *testing.T) {
 	tx := waf.NewTransaction()
 	tx.RequestBodyAccess = true
 	tx.ForceRequestBodyVariable = true
-	if _, err := tx.RequestBodyBuffer.Write([]byte("test")); err != nil {
+	if _, err := tx.requestBodyBuffer.Write([]byte("test")); err != nil {
 		t.Error(err)
 	}
 	if _, err := tx.ProcessRequestBody(); err != nil {
@@ -579,7 +732,7 @@ func TestTxReqBodyForceNegative(t *testing.T) {
 	tx := waf.NewTransaction()
 	tx.RequestBodyAccess = true
 	tx.ForceRequestBodyVariable = false
-	if _, err := tx.RequestBodyBuffer.Write([]byte("test")); err != nil {
+	if _, err := tx.requestBodyBuffer.Write([]byte("test")); err != nil {
 		t.Error(err)
 	}
 	if _, err := tx.ProcessRequestBody(); err != nil {
@@ -866,5 +1019,4 @@ func TestProcessorsIdempotency(t *testing.T) {
 			l.Close()
 		})
 	}
-
 }
diff --git a/internal/corazawaf/waf_test.go b/internal/corazawaf/waf_test.go
index e244aa0..f6af2fc 100644
--- a/internal/corazawaf/waf_test.go
+++ b/internal/corazawaf/waf_test.go
@@ -14,6 +14,7 @@ func TestNewTransaction(t *testing.T) {
 	waf.RequestBodyAccess = true
 	waf.ResponseBodyAccess = true
 	waf.RequestBodyLimit = 1044
+
 	tx := waf.NewTransactionWithID("test")
 	if !tx.RequestBodyAccess {
 		t.Error("Request body access not enabled")
diff --git a/internal/seclang/body_test.go b/internal/seclang/body_test.go
index 59d0c04..2376570 100644
--- a/internal/seclang/body_test.go
+++ b/internal/seclang/body_test.go
@@ -20,7 +20,7 @@ func TestRequestBodyAccessOff(t *testing.T) {
 	}
 	tx := waf.NewTransaction()
 	tx.ProcessURI("/", "POST", "http/1.1")
-	tx.RequestBodyBuffer.Write([]byte("test=123"))
+	tx.requestBodyBuffer.Write([]byte("test=123"))
 	tx.AddRequestHeader("Content-Type", "application/x-www-form-urlencoded")
 	tx.ProcessRequestHeaders()
 	tx.ProcessRequestBody()
@@ -39,7 +39,7 @@ func TestRequestBodyAccessOn(t *testing.T) {
 	}
 	tx := waf.NewTransaction()
 	tx.ProcessURI("/", "POST", "http/1.1")
-	if _, err := tx.RequestBodyBuffer.Write([]byte("test=123")); err != nil {
+	if _, _, err := tx.WriteRequestBody([]byte("test=123")); err != nil {
 		t.Error(err)
 	}
 	tx.AddRequestHeader("Content-Type", "application/x-www-form-urlencoded")
diff --git a/internal/seclang/rules_test.go b/internal/seclang/rules_test.go
index d2474bf..6f848ec 100644
--- a/internal/seclang/rules_test.go
+++ b/internal/seclang/rules_test.go
@@ -4,7 +4,6 @@
 package seclang
 
 import (
-	"io"
 	"regexp"
 	"strings"
 	"testing"
@@ -957,12 +956,17 @@ Content-Type: application/octet-stream
 
 BINARYDATA
 ------WebKitFormBoundaryABCDEFGIJKLMNOPQ--`)
-	_, err = io.Copy(tx.RequestBodyBuffer, body)
+	it, _, err := tx.ReadRequestBodyFrom(body)
 	if err != nil {
 		t.Error(err)
 		return
 	}
-	it, err := tx.ProcessRequestBody()
+
+	if it != nil {
+		t.Fatal(err)
+	}
+
+	it, err = tx.ProcessRequestBody()
 	if err != nil {
 		t.Error(err)
 		return
diff --git a/testing/coreruleset/coreruleset_test.go b/testing/coreruleset/coreruleset_test.go
index 23d40d5..d51187f 100644
--- a/testing/coreruleset/coreruleset_test.go
+++ b/testing/coreruleset/coreruleset_test.go
@@ -110,7 +110,7 @@ func BenchmarkCRSSimplePOST(b *testing.B) {
 		tx.AddRequestHeader("Accept", "application/json")
 		tx.AddRequestHeader("Content-Type", "application/x-www-form-urlencoded")
 		tx.ProcessRequestHeaders()
-		if _, err := tx.RequestBodyWriter().Write([]byte("parameters2=and&other2=Stuff")); err != nil {
+		if _, _, err := tx.WriteRequestBody([]byte("parameters2=and&other2=Stuff")); err != nil {
 			b.Error(err)
 		}
 		if _, err := tx.ProcessRequestBody(); err != nil {
@@ -144,7 +144,7 @@ func BenchmarkCRSLargePOST(b *testing.B) {
 		tx.AddRequestHeader("Accept", "application/json")
 		tx.AddRequestHeader("Content-Type", "application/x-www-form-urlencoded")
 		tx.ProcessRequestHeaders()
-		if _, err := tx.RequestBodyWriter().Write(postPayload); err != nil {
+		if _, _, err := tx.WriteRequestBody(postPayload); err != nil {
 			b.Error(err)
 		}
 		if _, err := tx.ProcessRequestBody(); err != nil {
diff --git a/testing/engine.go b/testing/engine.go
index e039e8d..63a4fc8 100644
--- a/testing/engine.go
+++ b/testing/engine.go
@@ -134,7 +134,7 @@ func (t *Test) SetRequestBody(body interface{}) error {
 	if t.magic {
 		t.RequestHeaders["content-length"] = strconv.Itoa(lbody)
 	}
-	if _, err := t.transaction.RequestBodyWriter().Write([]byte(data)); err != nil {
+	if _, _, err := t.transaction.WriteRequestBody([]byte(data)); err != nil {
 		return err
 	}
 	return nil
diff --git a/testing/modsecurity_test.go b/testing/modsecurity_test.go
index 806a99b..76b0059 100644
--- a/testing/modsecurity_test.go
+++ b/testing/modsecurity_test.go
@@ -133,7 +133,7 @@ func BenchmarkModSecurityCRSSimplePOST(b *testing.B) {
 		if err := tx.ProcessRequestHeaders(); err != nil {
 			b.Error(err)
 		}
-		if err := tx.AppendRequestBody(body); err != nil {
+		if err := tx.ReadRequestBodyFrom(body); err != nil {
 			b.Error(err)
 		}
 		if err := tx.ProcessRequestBody(); err != nil {