13. Content Munging¶
On the Coast of CoromandelWhere the early pumpkins blow,In the middle of the woodsLived the Yonghy-Bonghy-Bo.—Edward Lear, The Courtship of the Yonghy-Bonghy-Bo
Everything we’ve discussed so far in this book transforms the request — the URL, the query string, headers, environment variables. The server decides what to serve, but the content itself passes through untouched.
Sometimes that’s not enough. You may need to modify the response — the actual HTML, CSS, or other content that the server sends back to the client. Perhaps you’ve placed a reverse proxy in front of a backend application and the HTML it returns is full of hardcoded URLs pointing to the backend’s internal hostname. Or perhaps you need to inject a tracking snippet into every page, or strip out a legacy banner that the backend still inserts.
mod_rewrite can’t help you here. It has already done its work
by the time the response body is being assembled. For response
transformation, you need the output filter modules. We’re going to look
at three of them — mod_substitute, mod_sed, and
mod_proxy_html — and then at the filter framework that ties
them all together.
13.1. mod_substitute¶
mod_substitute performs search-and-replace on the response body
using a syntax borrowed from sed. It’s the simplest of the content
transformation modules, and the one you’ll reach for most often.
The core directive is Substitute, which takes a substitution
expression in the familiar s/pattern/replacement/flags form. Any
single character can serve as the delimiter — you don’t have to use
/ — which is handy when your patterns contain slashes, as URLs
tend to.
13.1.1. Basic usage¶
The most common use case is rewriting URLs in proxied content. Suppose
you have a reverse proxy in front of a backend server at
http://backend.internal:8080. The backend generates HTML with
absolute URLs pointing to itself:
<a href="http://backend.internal:8080/dashboard">Dashboard</a>
<img src="http://backend.internal:8080/images/logo.png">
You need these rewritten so they point to your public-facing proxy.
ProxyPassReverse handles Location and other headers, but it
doesn’t touch the body. That’s where Substitute comes in:
ProxyPass "/app" "http://backend.internal:8080"
ProxyPassReverse "/app" "http://backend.internal:8080"
<Location "/app">
AddOutputFilterByType SUBSTITUTE text/html
Substitute "s|http://backend.internal:8080|/app|ni"
</Location>
Let’s break down the flags:
nTreat the pattern as a literal string, not a regular expression. This is what you want when you’re matching a fixed URL — no need to worry about escaping dots and slashes.
iCase-insensitive match. Useful because some backends generate mixed case in URLs (
HTTP://vshttp://).
Other available flags are:
fFlatten line breaks. Normally,
mod_substituteprocesses the response one line at a time. Thefflag collapses the entire response (or the current buffer) into a single line before applying the substitution. Use this when the text you’re trying to match spans multiple lines — for example, an HTML tag with attributes split across lines.qQuote (escape) special characters in the replacement string. Use this when your replacement contains characters that would otherwise be interpreted as regex backreferences.
You can stack multiple Substitute directives — they’re applied in
order:
<Location "/app">
AddOutputFilterByType SUBSTITUTE text/html
Substitute "s|http://backend.internal:8080|/app|ni"
Substitute "s|backend.internal|www.example.com|ni"
</Location>
13.1.2. Handling long lines¶
By default, mod_substitute processes lines up to 1 MiB in
length. If your backend produces minified HTML or JSON where the entire
response is a single line, you may hit this limit. Increase it with:
SubstituteMaxLineLength 10m
The value accepts k (kilobytes) and m (megabytes) suffixes.
13.1.3. Using regular expressions¶
When literal matching isn’t enough, drop the n flag and use a full
regular expression. For example, to strip all onclick attributes
from the response:
Substitute "s/\s+onclick=\"[^\"]*\"//i"
Or to rewrite a family of image URLs:
Substitute "s|/old-images/(.*?\.(?:png|jpg|gif))|/new-images/$1|i"
Be conservative with regex substitutions on response bodies. A
poorly-anchored pattern applied to a large HTML document can produce
surprising results — or silently corrupt content. Always test
thoroughly, and prefer the n (literal) flag when exact string
matching will do.
13.1.4. Content type filtering¶
Notice the AddOutputFilterByType directive in the examples above.
This is important: you almost certainly don’t want to run text
substitutions on binary content like images or PDFs. Always scope your
Substitute directives to the appropriate content types:
# Only apply to HTML and CSS
AddOutputFilterByType SUBSTITUTE text/html text/css
If you need to apply substitutions to all text content, you can add
text/plain, application/javascript, application/json, and
so on — but think twice before casting too wide a net.
13.2. mod_sed¶
Where mod_substitute gives you a single s/find/replace/
operation per directive, mod_sed gives you the full sed
stream-editing language. It’s more powerful, but also more complex, and
you’ll need it far less often.
mod_sed provides two directives:
OutputSedApplies a
sedcommand to the response body (output filter).InputSedApplies a
sedcommand to the request body (input filter). This is unusual — it means you can transform POST data before your application sees it. There aren’t many use cases for this in practice, but it’s there if you need it.
13.2.1. Basic usage¶
Here’s a simple example: stripping all <script> blocks from proxied
HTML, perhaps as a crude XSS mitigation on a legacy backend:
<Location "/legacy">
AddOutputFilterByType Sed text/html
OutputSed "s/<script[^>]*>.*<\/script>//gi"
</Location>
Note the filter name is Sed (capital S), not SUBSTITUTE.
13.2.2. When to reach for mod_sed¶
For simple search-and-replace, mod_substitute is easier and
sufficient. Reach for mod_sed when you need capabilities that
go beyond a single substitution:
Address ranges: Apply a command only to lines matching a pattern or falling within a line range. For example, transform only lines inside a
<div class="legacy">block.Multiple commands: Chain several
sedcommands in sequence. You can useOutputSedmultiple times, or separate commands with semicolons if yoursedsyntax supports it.Delete operations: The
dcommand deletes entire lines matching a pattern, which is cleaner than substituting them with an empty string.
# Delete all HTML comment lines from the response
OutputSed "/^<!--/d"
In practice, if you find yourself writing complex sed programs inside
your Apache configuration, it’s worth stepping back and asking whether
the transformation should happen at the application level instead. A
few lines of sed in the config is fine; a 20-line sed script
embedded in OutputSed directives is a maintenance hazard.
13.3. mod_proxy_html¶
mod_proxy_html takes a fundamentally different approach from the
two modules above. Instead of doing blind text search-and-replace, it
actually parses the HTML and rewrites only the URLs it finds in
elements and attributes. It understands <a href>, <img src>,
<form action>, <link href>, and all the other places where URLs
appear in HTML.
This makes it both safer and more precise for URL rewriting — it won’t
accidentally mangle text that happens to look like a URL, because it
only touches actual attribute values in HTML elements. On the other
hand, it’s useless for non-HTML content, and it does nothing for URLs
embedded in JavaScript strings or inline CSS (unless you enable
ProxyHTMLExtended).
13.3.1. A worked example¶
Here’s the classic reverse proxy scenario. You proxy /app to a
backend at http://backend:8080/, and the backend generates HTML with
absolute paths rooted at /:
<a href="/dashboard">Dashboard</a>
<link rel="stylesheet" href="/css/style.css">
<form action="/api/submit" method="post">
These paths are wrong from the client’s perspective — the client needs
to see /app/dashboard, /app/css/style.css, and
/app/api/submit. Here’s how mod_proxy_html handles it:
ProxyPass "/app" "http://backend:8080"
ProxyPassReverse "/app" "http://backend:8080"
<Location "/app">
ProxyHTMLEnable On
ProxyHTMLURLMap "/" "/app/"
</Location>
ProxyHTMLURLMap takes a from pattern and a to string, similar to
Substitute but operating on parsed URL attributes rather than raw
text. This single directive will rewrite every href, src,
action, and similar attribute in the HTML response.
13.3.2. Extending to CSS and JavaScript¶
By default, mod_proxy_html only processes HTML element
attributes. If your backend also embeds URLs in inline CSS (url(...)
values) or JavaScript strings, you’ll need:
ProxyHTMLExtended On
This enables a more aggressive mode that scans for URL-like patterns beyond just HTML attributes. It’s slower and has a higher risk of false positives, so only enable it if you actually need it.
13.3.3. Setting the output doctype¶
mod_proxy_html re-serializes the HTML after rewriting, which
means it needs to know whether to produce HTML 4, XHTML, or HTML 5.
Use ProxyHTMLDocType to control this:
# Output as HTML 5
ProxyHTMLDocType "<!DOCTYPE html>" HTML
# Or for legacy XHTML
ProxyHTMLDocType "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"
\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">" XHTML
13.3.4. Comparison with mod_substitute¶
Use mod_proxy_html when:
You need to rewrite URLs in HTML attributes, and you want something that understands HTML structure.
Your backend produces well-formed HTML and the URLs follow standard patterns.
You want to avoid accidentally rewriting text content that happens to contain URL-like strings.
Use mod_substitute when:
You need to rewrite content that isn’t HTML, or that appears outside of standard HTML attributes (e.g., in
<script>blocks, JSON responses, plain text).You need regex-powered transformations, not just URL mapping.
The response isn’t well-formed HTML and would confuse a parser.
In a complex reverse proxy setup, you may end up using both —
mod_proxy_html for the structural URL rewriting and
mod_substitute for the edge cases it doesn’t cover.
13.4. Filters¶
All three modules above are implemented as httpd output filters. To use them effectively, it helps to understand what that means.
When httpd generates or receives a response, the content doesn’t go
directly to the client. Instead, it passes through a chain of filters
— a pipeline of modules that can inspect, transform, or replace the
content as it flows through. You’ve already been using this concept
implicitly: mod_deflate (gzip compression) is a filter,
mod_ssl (encryption) is a filter, and the content transformation
modules in this chapter are filters.
13.4.1. Adding filters to the chain¶
The most common way to add a filter is AddOutputFilterByType, which
applies the filter only to responses of a specific MIME type:
AddOutputFilterByType SUBSTITUTE text/html
AddOutputFilterByType DEFLATE text/html text/css application/javascript
You can also use SetOutputFilter to apply a filter unconditionally
to everything in a given context:
<Location "/api">
SetOutputFilter Sed
</Location>
But this is rarely what you want — applying text transformations to binary content will corrupt it.
13.4.2. Filter ordering¶
Filters run in a defined order. Content transformation filters (SUBSTITUTE, Sed, proxy-html) run before compression (DEFLATE), which runs before encryption (SSL). This means your substitutions operate on the uncompressed, unencrypted content — which is what you want.
If you stack multiple content transformation filters, they run in
the order they were added. A Substitute applied before an
OutputSed will see the original content; the OutputSed will see
the already-substituted content.
13.4.3. Conditional filtering with mod_filter¶
AddOutputFilterByType is good enough for most cases, but
mod_filter gives you much finer control. You can apply a
filter based on request headers, environment variables, response
headers, or content type — and you can build conditional pipelines:
FilterDeclare rewrite_urls CONTENT_SET
FilterProvider rewrite_urls SUBSTITUTE "%{Content_Type} =~ /text\/html/"
FilterChain rewrite_urls
This declares a filter called rewrite_urls, tells httpd to use the
SUBSTITUTE provider when the content type is text/html, and adds it
to the filter chain. The FilterProvider directive can test against
any of the standard httpd variables, giving you conditional logic that
AddOutputFilterByType alone can’t express.
13.4.4. The escape hatch: mod_ext_filter¶
When none of the built-in filter modules do what you need,
mod_ext_filter lets you pipe the response body through an
arbitrary external program. Define the filter, then add it to the
chain:
ExtFilterDefine fix-encoding cmd="/usr/bin/iconv -f ISO-8859-1 -t UTF-8"
<Location "/legacy-app">
SetOutputFilter fix-encoding
</Location>
This is powerful — you can use any command-line tool — but it comes at
a cost. Every request that triggers the filter forks a new process, which
is slow and resource-intensive under load. Use mod_ext_filter
as a last resort: for prototyping, for low-traffic endpoints, or when
the transformation is truly impossible with the built-in modules.
13.4.5. Putting it all together¶
The key insight for this book’s narrative: mod_rewrite
transforms the request — the URL, headers, and environment variables
that determine what content the server will produce or fetch. The
filter modules discussed in this chapter transform the response — the
actual content that the client receives.
They’re complementary tools. A typical reverse proxy configuration might
use mod_rewrite (or ProxyPass) to route requests to the
correct backend, ProxyPassReverse to fix response headers, and then
mod_substitute or mod_proxy_html to fix URLs
embedded in the response body. Understanding both sides of the
request-response lifecycle gives you full control over how your server
behaves.