How can I keep a Jahia site publicly reachable while restricting authoring and administration to a trusted network?
Question
How can I keep a Jahia site publicly reachable while restricting authoring and administration to a trusted network?
Answer
A reverse-proxy rule is a URL filter, not authorization. It reduces the surface an anonymous internet client can reach. It cannot constrain someone who is on the trusted network, it cannot neutralise a stolen session cookie, and it is not a substitute for patching or for Jahia's own security settings.
Treat it as a second independent layer in front of Jahia's authentication, never as a replacement for it.
Applies to Jahia 8.2.3.2. The path behaviour in sections 1 and 4 was measured on that version; the proxy configuration in section 5 was measured on HAProxy 3.2.25. Where a claim was not measured, the sources comment at the end of this entry says so explicitly.
1. Know which paths are back office
These are the path families to restrict. The right-hand column is what a stock
8.2.3.2 actually answered, probed anonymously and again as root:
| Path family | What it is | Measured on 8.2.3.2 |
|---|---|---|
/jahia/ | Back office (jContent, administration) | 401 anon, 200 authenticated |
/cms/edit/, /cms/editframe/ | Edit mode | 401 anon |
/cms/contribute/, /cms/contributemode/ | Contribute mode | 401 anon |
/cms/studio/, /studio/ | Studio | 401 anon |
/administration/, /cms/administration | Administration | 401 anon, 302 authenticated |
/tools/, /modules/tools/, /tools/osgi/ | Tools, including the Groovy console and the OSGi web console | 302 to login anon |
/start, /welcome, /welcome/* | WelcomeServlet | 401 anon, serving a login page |
/welcome/adminmode | Administration mode entry point | 500 anon, returning a 35 KB error page |
/server/ | Module-provided | 404 on a bare install; module dependent |
/modules/api/ | JCR REST API | 405 to GET |
/modules/graphql | GraphQL | 400 to GET; it is POST-only |
/modules/graphqlws | GraphQL over websocket | module-provided |
/modules/provisioning, /modules/modulemanager | Provisioning, module manager | module-provided |
/repository/, /dav/ | WebDAV mount points | 404 to GET; 501 to every WebDAV verb on a bare install - see the warning |
/cms/export/, /cms/import | Content export and import | 401 anon |
/cms/find* | Principal and user lookup | not distinguishable on a bare 8.2.3.2 - see the note below the table |
/cms/render/default/ | Render in the edit workspace | 404 anon, 200 authenticated |
/files/default/ | File delivery from the edit workspace | 404 anon, 200 with the file authenticated |
/files/preview/ | File delivery, preview workspace | 404 - no such workspace on 8.2.3.2 |
/gwt/, *.gwt, /engines/ | Legacy back-office plumbing. *.gwt is a suffix mapping - see below | 404 on a bare install; module dependent |
On /cms/find*, be careful what you claim. An earlier version of this
entry said these endpoints enumerate the user directory. That was not measured
and is not stated here. On a bare 8.2.3.2, /cms/findPrincipal, /cms/findUser,
/cms/findGroup and /cms/findUsersAndGroups all return a byte-identical
1396-byte 400, the same as /cms/thisDoesNotExist; the .do spelling returns
302 to /error.html, and so does /cms/totallyMadeUp.do. So nothing on a bare
install distinguishes them from a path that does not exist. They are listed
because they are a coherent back-office namespace that modules populate, which
is reason enough to restrict the family - not because anything here proved what
they do.
Two consequences for the rule. Match them by prefix, /cms/find, not by
exact name: an exact match misses /cms/findPrincipal.do, misses
findUsersAndGroupsInAcl, and misses whatever the next module adds. And note
that a bare prefix is the right tool here precisely because the whole
namespace below it is privileged - which is not true of /files/ or
/generated-resources/, and is why those get workspace-specific prefixes
instead.
These stay public, and breaking them breaks the site:
/files/live/…: public file delivery. Measured:404before publication,200anonymously once the file is published. A rule matching/files/wholesale breaks every published image and document on the site - restrict the workspace-specific prefixes, never the servlet./generated-resources/…: the aggregated CSS and JavaScript of the public site. Measured: the real asset returns200and 14 KB oftext/cssanonymously, while an invented name under the same prefix returns404- which is the control showing the200is the servlet serving, not a generic answer. Same shape as/files/: the prefix looks like plumbing and is not./cms/login: 200 anonymously. If your editors reach the back office through the public hostname, blocking login locks them out. If they do not, see the note on/startbelow.
/cms/render/live/… is the interesting case, because the obvious assumption
about it is wrong. It renders the public site, so it looks untouchable. But
seo-urlrewrite.xml, shipped at
/usr/local/tomcat/webapps/ROOT/WEB-INF/etc/config/, carries outbound rules
that strip /cms/render/live/ from every link Jahia emits, and an inbound rule
that puts it back:
<from>^/cms/([^\?]*)(\?.*)?$</from>
<to last="true">/cms/render/live/$1</to>
urlRewriteSeoRulesEnabled and urlRewriteRemoveCmsPrefix both default to
true. Measured on two independent stock 8.2.3.2 containers: a live page emits
href="http://host/sites/systemsite/home.html" and zero hrefs containing
/cms/. So on a default deployment the public site does not use /cms/ at all,
and restricting the whole /cms/ tree is viable.
That is a decision to make on evidence, not on either assumption. Before
restricting /cms/ wholesale, confirm on your install that the SEO rules are
enabled and that no content hard-codes a /cms/ link - a single editor-authored
absolute URL is enough to break a page. If you cannot confirm it, restrict the
specific /cms/ families in the table and leave the rest alone, which is what
the worked example in section 5 does.
Finding the list for your own install
The table above is a starting point, not a closed set. The authoritative list of
what Jahia routes is WEB-INF/web.xml in the deployed ROOT webapp:
docker exec <container> grep -A2 '<servlet-mapping>' \
/usr/local/tomcat/webapps/ROOT/WEB-INF/web.xml
Reading it on 8.2.3.2 turns up three things a path list tends to miss:
- A suffix mapping.
*.gwtis mapped to the GWT dispatcher servlet, so a request ending in.gwtis routed there from any prefix, not only from under/gwt/. A prefix rule cannot express that; you need a suffix match. This is read fromweb.xml, not measured:/sites/systemsite/home.gwt,/sites/systemsite/home.zzzand/sites/systemsite/homeXgwtall return the same byte-identical 1290-byte 404 page, so the status tells you nothing either way. The routing is in the mapping regardless of what the servlet then answers. Check your own modules for the same shape before assuming prefixes are enough. - Servlets neither this entry nor the usual lists mention, among them
/atmosphere/(500 anonymously, with a 31 KB error page),/flow/,/initializationCompleted/and/validateTicket(both 200 anonymously with an empty body). Judge each one:/flow/backs Jahia's own form flows and blocking it can break login, so this entry does not recommend restricting it. - Prefixes that must stay public, such as
/generated-resources/above. The file does not tell you which is which. Only a probe does.
The point is the method rather than the list: enumerate the mappings, then probe each one anonymously and authenticated, with a control.
A 404 does not mean the endpoint is absent, and a 200 to an
OPTIONSdoes not mean it is present./files/default/answers404to an anonymousGETon 8.2.3.2 and200with the file to an authenticated one: the status tracks who is asking, not whether the path exists. In the other direction,/repository/,/dav/and/files/all answer200to anOPTIONSwhile returning Jahia's own 404 page to aGET- but that200is the servlet container answeringOPTIONSgenerically. Measured on a bare 8.2.3.2, every WebDAV verb (PROPFIND,MKCOL,COPY,MOVE,LOCK) returns501, noDAV:compliance header is sent, andAllow:lists only ordinary HTTP verbs - so WebDAV is not implemented on that install. It is module-provided, and deploying the module makes the path live without anything you can see today changing. The same is true of several other paths above. Restrict the path family regardless of what your install answers.
2. Decide what "trusted" means, and where it usually goes wrong
If Jahia sits behind a CDN or any load balancer, the proxy no longer sees the
client's real address, so the instinct is to read X-Forwarded-For. Which
occurrence you read decides whether the restriction works at all.
X-Forwarded-For is built left to right, and each hop appends the address it
saw. So the leftmost element is whatever the client chose to send: it is
caller-controlled, always.
| What you gate on | Result |
|---|---|
Leftmost X-Forwarded-For | No protection. One extra header on an ordinary request is enough to be treated as trusted |
Rightmost X-Forwarded-For, unguarded | Resists the header above, but any caller that reaches the proxy directly simply puts a trusted address last |
| The TCP source address | Cannot be set by the caller, but only equals the real client when your proxy is the first hop |
The workable rule: gate on the TCP source address, and only fall back to a forwarded header when the connection itself came from a hop you already trust. If you must read a forwarded header, read the occurrence your own infrastructure appended, never the leftmost, and treat the list of trusted hops as an authentication boundary, because that is exactly what it is. Everything inside that range can assert an identity.
The origin lock
Gating on the source address closes one hole and leaves a larger one open. If the origin is reachable by address, then every rule you wrote at the CDN (its WAF, its rate limits, its header injection) is optional from the caller's point of view. Overriding DNS, or simply connecting to the origin IP, skips the entire tier.
Close it by requiring a shared secret that the CDN injects as an origin custom header, and refusing any request that arrives without it. Two properties are what make it work, and both are easy to get wrong:
- It must be an origin custom header, not a forwarded header. CloudFront's origin custom headers are attached by the CDN on the way to the origin and override whatever the viewer sent, so a caller cannot supply the value themselves. A header you merely forward is caller-controlled and locks nothing. Other CDNs have an equivalent; check that yours overrides rather than merges.
- The CDN must be the only way in. Keep a narrow exemption for traffic that legitimately reaches the origin directly (a platform health probe is the usual one), and point everything else at the CDN. An external monitor or a cache warmup job that talks to the origin by address will start getting refused, and that is the rule working.
Section 5 assembles the whole thing.
3. Decide what to answer
- For humans, redirect. An editor who follows an old bookmark should land on the authoring hostname, not a wall.
- For machine endpoints, refuse flatly. A redirect is the wrong answer for an
API client, and sending it puts your internal authoring hostname into scripts
and logs.
404also says less about your deployment than403does.
Watch out for paths an ordinary logged-in visitor legitimately uses:
/cms/dashboard and /cms/mysettings are reachable by any authenticated member.
Redirecting those to an authoring host they are not allowed to reach just hands
them a 403.
4. Normalise before you match
This is the part most rules get wrong. Your proxy matches the raw request path; the servlet container routes on the decoded, normalised one. Wherever the two disagree about what the URL is, your rule and the application are looking at different strings, and the rule is the one that misses.
Measured on 8.2.3.2, all of these reach the same handler as /tools/:
/tools/ 302 -> /modules/tools/ the canonical spelling
/TOOLS/ 302 -> /modules/tools/ case
/ToOlS/ 302 -> /modules/tools/ mixed case
/%74ools/ 302 -> /modules/tools/ percent-encoded
/%74%6f%6f%6c%73/ 302 -> /modules/tools/ fully percent-encoded
Be precise about what this means. Jahia authenticates every one of those
spellings: requested anonymously they all end at /cms/login, exactly as
/tools/ does. Jahia is doing its job. What the variants defeat is your rule.
A proxy matching the literal string /tools/ does not match /TOOLS/, so for
that spelling the restriction you added simply is not applied and you are relying
on the application by itself, which is the situation you added the rule to get
out of.
So, before matching, URL-decode (more than once), strip matrix parameters, collapse repeated slashes, and lowercase. Then match against the result.
Dot segments and encoded percent signs are best refused outright rather than
canonicalised: correct canonicalisation is harder than rejection, and neither
has a legitimate use on a back-office path. On 8.2.3.2 Tomcat itself already
refused /foo/../tools/, /tools;jsessionid=x/, /tools%2f and
/modules/%2574ools/ with 400 or 404, but that is servlet-container
configuration, it is not yours to guarantee on every deployment, and a rule that
depends on it is a rule you cannot reason about. Refuse them at the edge too.
5. A worked example
HAProxy, in a frontend. Four ideas, in this order: normalise the path, derive
trust from the socket, require the CDN's secret, then match. They port directly
to nginx or Apache.
Read it top to bottom. In HAProxy the order of http-request lines is the order
of evaluation, and several of the mistakes called out below are ordering mistakes
rather than logic mistakes.
5.1 Normalise, and declare the gates
# one normalised copy of the path, matched by every rule below
http-request set-var(txn.p) path,url_dec,url_dec,regsub(;[^/]*,,g),regsub(/+,/,g),lower
# GATE 1 - the real socket peer. No header can change this.
acl from_trusted src 192.0.2.0/24
# GATE 2 - the secret the CDN injects. openssl rand -hex 32
acl cf_verify req.hdr(X-Origin-Verify) -m str REPLACE_WITH_A_LONG_RANDOM_SECRET
# the hostnames this frontend governs
acl host_public hdr(host),field(1,:),lower -m str www.example.com
acl host_authoring hdr(host),field(1,:),lower -m str authoring.example.com
# Carry that decision into a variable, because the RESPONSE rules in 5.7
# cannot test it. `hdr(host)` in a response rule reads the response headers,
# which have no Host - HAProxy warns "will never match" and the rule
# silently does nothing. A txn variable set here survives into the response
# phase, which is the only thing that does.
http-request set-var(txn.public) str(no)
http-request set-var(txn.public) str(yes) if host_public
# traffic that legitimately arrives WITHOUT the secret.
# Jahia answers /ping.jsp itself, which is why platforms use it as a liveness
# probe - and that probe connects to the origin by address, so it never
# transits the CDN and never carries the header. Without this exemption the
# origin lock answers 403 and the platform marks the node down.
acl is_liveness var(txn.p) -m str /ping.jsp
# a switch, so the same file can ship to an environment with no CDN in front.
# HAProxy cannot define one acl in terms of another, so this is a plain
# always_true rather than `acl require_origin_secret host_public`.
acl require_origin_secret always_true
# acl require_origin_secret always_false # <- dev: no CDN in front
5.2 A diagnostic that names the gate that refused
Worth writing before you need it, and worth placing here, above everything it reports on, for the reasons below. It names which gate said no, and it never echoes the secret, only whether it matched. Note that it sets every variable it prints: a probe that reads a variable the rest of your config happens to populate is a probe that renders blank the moment you reuse it somewhere else.
# http-request set-var(txn.dbg_trusted) str(no)
# http-request set-var(txn.dbg_trusted) str(yes) if from_trusted
# http-request set-var(txn.dbg_secret) str(no)
# http-request set-var(txn.dbg_secret) str(yes) if cf_verify
# http-request return status 200 content-type "text/plain" lf-string "src=%[src] host=%[req.hdr(host)] path=%[var(txn.p)] trusted=%[var(txn.dbg_trusted)] secret_ok=%[var(txn.dbg_secret)]" if host_public { path /edge-check }
Placement decides whether it tells you anything:
- After every
aclit names. HAProxy resolves ACL names at parse time, so referencingcf_verifyabove its ownaclline is a config error (the proxy refuses to start), not a runtime miss. - Before the front-door denies of 5.3. Any lower and those denies answer first, so the probe itself returns 403 and tells you nothing. Measured: placed last, a request to the probe without the secret returns 403 rather than a report.
- Before the
del-headerof 5.5. Below it, the probe tests a header that has already been removed and can only ever answersecret_ok=no. Measured: placed last, a request carrying the correct secret still reportssecret_ok=no. That misreading cost real time on the deployment this entry generalises from.
Then scope it to one hostname and remove it afterwards. Unscoped, it answers
on every Host the frontend serves and hands your internal addresses to anyone
who asks.
5.3 The front door
# The authoring hostname is trusted-network-only, full stop. No CDN secret is
# accepted here: the CDN never fronts this name, so a request carrying one is
# either misrouted or forged.
http-request deny deny_status 403 if host_authoring !from_trusted !is_liveness
# The public hostname must arrive through the CDN. Operators on the trusted
# network may still browse it directly - they simply get the public
# experience, which is the point of keeping the two hostnames separate.
http-request deny deny_status 403 if host_public !from_trusted !cf_verify !is_liveness require_origin_secret
Giving each hostname exactly one response variant matters more than it looks. If the public hostname behaved differently for trusted callers, a cache in front of it would have two variants of one URL and no reliable way to choose between them.
5.4 Refuse the confusing spellings, then match paths
# refuse the spellings that exist only to confuse a filter
acl bad_dotseg var(txn.p) -m sub ../
acl bad_dotseg var(txn.p) -m sub ./
acl bad_pctpct path -m sub %25
http-request deny deny_status 400 if host_public bad_dotseg
http-request deny deny_status 400 if host_public bad_pctpct
# EVERY family is declared TWICE: an exact match for the bare path, and a
# /-terminated prefix for everything beneath it. `-m beg /tools/` on its own
# does NOT match a request for /tools, which then reaches the application
# untouched - measured, not theoretical. Repeating an acl NAME is an OR,
# which is what keeps this readable; 5.6 relies on the same property.
# humans get sent to the authoring host
acl p_backoffice var(txn.p) -m str /jahia /administration /cms/administration /studio
acl p_backoffice var(txn.p) -m beg /jahia/ /administration/ /studio/
acl p_backoffice var(txn.p) -m beg /cms/edit/ /cms/editframe/ /cms/adminframe/
acl p_backoffice var(txn.p) -m beg /cms/contribute/ /cms/contributemode/
acl p_backoffice var(txn.p) -m beg /cms/studio/ /cms/studiovisual/
http-request redirect prefix https://authoring.example.com code 302 if host_public !from_trusted p_backoffice
# machine surfaces are refused flatly. /gwt/ and /engines/ belong HERE and
# not above: redirecting a back-office RPC call to another hostname does not
# help the caller, it only breaks it somewhere else.
acl p_api var(txn.p) -m str /modules/api /modules/provisioning /modules/modulemanager
acl p_api var(txn.p) -m str /modules/graphqlws /repository /dav /cms/export /cms/import
acl p_api var(txn.p) -m beg /modules/api/ /modules/provisioning/ /modules/modulemanager/
acl p_api var(txn.p) -m beg /modules/graphqlws /repository/ /dav/ /cms/export/
acl p_api var(txn.p) -m beg /cms/import /gwt/ /engines/
acl p_tools var(txn.p) -m str /tools /modules/tools /cms/tools
acl p_tools var(txn.p) -m beg /tools/ /modules/tools/ /cms/tools/
# NOTE the lowercase spelling: txn.p was lowercased in 5.1, so an ACL
# value with a capital in it can never match. /validateTicket is the
# trap - it is the one path here whose real spelling is mixed case.
acl p_api var(txn.p) -m str /server /validateticket
acl p_api var(txn.p) -m beg /server/
# Administration mode, reached through the WelcomeServlet. Restricting the
# whole /welcome family and /start is the stricter posture and it puts
# LOGIN ITSELF on the trusted network; keep only the adminmode line if your
# editors sign in through the public hostname. Pick one deliberately -
# leaving both commented out is a decision too.
acl p_welcome var(txn.p) -m str /welcome/adminmode
acl p_welcome var(txn.p) -m beg /welcome/adminmode/
# acl p_welcome var(txn.p) -m str /start /welcome
# acl p_welcome var(txn.p) -m beg /welcome/
# A SUFFIX mapping: *.gwt reaches the GWT dispatcher from any prefix, so no
# amount of prefix matching covers it. Read from web.xml on 8.2.3.2.
acl p_suffix var(txn.p) -m end .gwt
# /cms/find is matched as a BARE prefix, deliberately. An exact match plus
# /cms/find/ would miss /cms/findPrincipal.do and findUsersAndGroupsInAcl.
# A bare prefix is safe here only because the whole namespace below it is
# privileged - do not copy this shape onto /files/ or /generated-resources/.
acl p_find var(txn.p) -m beg /cms/find
# the unpublished workspaces - RENDERED pages and FILE delivery. The second
# is the one that gets forgotten: restricting /cms/render/default/ while
# leaving /files/default/ open still serves unpublished binaries to anyone
# holding a back-office credential (measured: 200 with the file, as root).
# NOTE /files/live/ is deliberately NOT here - it serves the published
# assets of the public site. /files/preview/ resolves to nothing on 8.2.3.2
# (the only JCR workspaces are default and live); it is kept as defence in
# depth, not because it answers today.
acl p_unpublished var(txn.p) -m beg /cms/render/default/ /cms/render/preview/
acl p_unpublished var(txn.p) -m beg /files/default/ /files/preview/
http-request deny deny_status 404 if host_public !from_trusted p_api
http-request deny deny_status 404 if host_public !from_trusted p_tools
http-request deny deny_status 404 if host_public !from_trusted p_find
http-request deny deny_status 404 if host_public !from_trusted p_welcome
http-request deny deny_status 404 if host_public !from_trusted p_suffix
http-request deny deny_status 404 if host_public !from_trusted p_unpublished
192.0.2.0/24 is the RFC 5737 documentation range; substitute your own.
5.5 Request header hygiene
Two deletes. The second matters more than it looks.
# Never let the origin secret reach the application. Unconditional, NOT
# `if host_public`: one CDN distribution often fronts several hostnames,
# including ones this frontend does not otherwise govern, and a host-scoped
# delete would forward your own secret to the back end on exactly the
# hostname you were not thinking about.
http-request del-header X-Origin-Verify
# Strip credentials on the public path.
http-request del-header Authorization if host_public
http-request del-header Proxy-Authorization if host_public
Jahia accepts Basic credentials, JWTs and personal API tokens on the same
Authorization header, and it authenticates on any request rather than only
on a login endpoint. GET /en/home.html carrying credentials renders as that
user. None of the path rules above help: the caller never needs to touch
/jahia/ at all.
Confirm one thing before shipping this: the public site must authenticate with
a session cookie, not with the Authorization header. If any public API on
that hostname takes a Bearer token, stripping it breaks that API. Search the
modules your public site actually runs for Authorization before deciding.
Where it is unused, this removes an entire authentication surface at no
functional cost.
Strip rather than deny, deliberately. Stripping makes the request anonymous,
which is the intent. Denying returns 403 for a URL whose CDN cache key may not
include Authorization - one caller can then put that 403 in the shared cache
for everyone. If you would rather fail loudly, deny and give the CDN a cache
policy that includes the header.
5.6 Method floor
A ceiling on verbs is cheaper than a list of paths, and it covers the endpoints you forgot to list.
acl method_public method GET HEAD POST
http-request deny deny_status 405 if host_public !method_public
# GraphQL is POST-only. Note the exact match PLUS the /-terminated prefix:
# `-m beg /modules/graphql` on its own also matches /modules/graphqlws, and
# the looser rule sitting first would answer for it.
acl is_graphql var(txn.p) -m str /modules/graphql
acl is_graphql var(txn.p) -m beg /modules/graphql/
http-request deny deny_status 405 if host_public is_graphql !{ method POST }
It sits after the path rules deliberately. A wrong-method request to a
restricted path is then refused by the path rule and answers 404 like every
other request to that path; put the method floor first and the same request
answers 405, which confirms the path exists. Requests to paths you do serve
still get 405, which is the correct answer there.
One line removes PUT, DELETE, PATCH, PROPFIND, MKCOL and the rest of the WebDAV
verb set. That is what actually closes the endpoints from the warning in section
1 - the ones answering 404 to a GET and 200 to an OPTIONS - without
depending on having listed every path they are mounted on.
5.7 Headers on every response, including the ones HAProxy writes itself
http-response rules run only on responses that came back from a server.
Everything this config refuses - the 403 front door, the 404 path rules, the 405
method floor - is generated by HAProxy and never passes through them.
Measured on HAProxy 3.2.25, setting one header three ways:
| Rule used | 200 from the back end | 404 written by HAProxy |
|---|---|---|
http-response set-header | present | absent |
http-after-response set-header | present | present |
| both together | present once | present once |
So http-after-response alone does the whole job, and the parallel
http-response list many configs carry is redundant:
http-after-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains" if { var(txn.public) -m str yes }
http-after-response set-header X-Content-Type-Options nosniff if { var(txn.public) -m str yes }
http-after-response set-header Referrer-Policy strict-origin-when-cross-origin if { var(txn.public) -m str yes }
http-after-response set-header X-Frame-Options DENY if { var(txn.public) -m str yes }
http-after-response del-header X-Powered-By if { var(txn.public) -m str yes }
Four details worth getting right:
set-header, notadd-header.set-replaces,add-appends. Measured: withadd-headeron both rule sets, a back-end response carries the header twice;set-headeron both carries it once.- Scope on a variable, not on
host_public. A response rule cannot read the request'sHost. Writingif host_publichere parses, runs, and never matches - HAProxy says so at startup with "acl 'host_public' will never match because it only involves keywords that are incompatible with 'frontend http-response header rule'". Read your startup warnings; that one is the difference between these headers being set and not. - Scope it to hostnames you own. These change what every response on that name says. If the same proxy fronts a site another team owns, setting them unscoped is not your call to make.
- No
preloadon HSTS. Preload declares that the registrable domain and every subdomain is HTTPS-only indefinitely. Asserting that from one hostname is at best inert and at worst a commitment the owners of the sibling names never made.max-agewithincludeSubDomainsgives the protection without the cross-team claim. http-after-responseneeds HAProxy 2.2 or later. On 2.0 and 2.1 fall back tohttp-responseand accept that generated responses go out bare - or upgrade, because that gap is the whole point of this section.
5.8 Rotating the secret
Repeated acl lines of the same name are OR'd, which gives you a clean overlap:
- Add a second
cf_verifyline carrying the new value, reload. Both are now accepted. - Update the CDN's origin custom header to the new value.
- Remove the old
cf_verifyline, reload.
There is never a window in which a legitimate request is refused.
Practical warnings
- A prefix can collide with a sibling.
-m beg /modules/graphqlalso matches/modules/graphqlws. If the first rule answers differently from the second, the wrong one wins and you have leaked that the endpoint exists. Use an exact match plus a/-terminated prefix when one path is a prefix of another - 5.6 does exactly that, and 5.4 does it for every family, because-m beg /tools/alone does not match a request for/tools. - Every rule carries an explicit host condition. If one proxy fronts several hostnames, a rule without one silently applies to all of them.
- This example is scoped, not deny-by-default, so a hostname you did not name gets nothing. A request for a third hostname reaches the back end with no origin lock, no source gate and none of the path rules. That is the right default when one proxy fronts sites that have not opted in, but it means adding a site is an explicit act. If every hostname your proxy answers on should be governed, refuse unrecognised ones instead of relying on this.
- If you paste this into a hosted configuration UI, avoid backslashes. Line
continuations and escape sequences such as
\nhave been seen to be interpreted by the form before the proxy ever parses them, splitting one directive across several lines and failing the reload with errors that point at the wrong place. Everything above is deliberately backslash-free and fits on single lines for that reason. If a long line still gets mangled, split the rule into several short ones rather than continuing it.
6. Prove it, from outside
Write the checks down and run them from a genuinely untrusted network, not from the office, and not from the VPN you just allowed. Measuring from inside the trusted network measures the wrong path and will tell you everything is fine.
At minimum, assert that from outside:
- each restricted path answers the status you intended, and so does its uppercase, percent-encoded and trailing-slash-variant spelling;
/cms/render/live/…and an ordinary page still answer 200;/cms/loginstill answers 200, if your editors need it;- the same requests from the trusted network still reach the back end;
- nothing leaked onto a different hostname the same proxy serves.
Two techniques make the difference between a verification and a ritual:
- Compare more than the status code. Your proxy's built-in error pages are
small and byte-exact, while an application error page is a full styled document
an order of magnitude larger. The application also answers
404on a path it does not serve, so a check comparing status codes alone passes identically whether or not your config is deployed. Compare the response body size too, and you are testing the edge rather than hoping. - The log already names the culprit. In HAProxy's default log format a denial
written by your own config shows no server and a proxy termination state
(
<NOSRV>withPR--). A 403 that came from Jahia shows a real backend and server name with----. You do not need a custom log format to tell the two apart.
And once the origin lock of 5.3 is on, add one more check: request the public hostname without the secret header, straight at the origin address. It must be refused. That single request is the whole point of the lock, and it is the one nobody runs.
7. What this does not solve
Worth stating to whoever signs off on the change:
- It cannot constrain anyone already on the trusted network.
- It cannot invalidate a session cookie that was minted legitimately and replayed.
- A per-IP rate limit only counts per IP; it does not recognise the same actor arriving from many addresses. Account lockout belongs in the application.
- Parameters are not inspected. An endpoint you do allow is reachable with any query string or request body, so the application remains responsible for what it accepts.
- It is not a substitute for keeping Jahia up to date, nor for Jahia's own settings such as secured file upload, the security profile, CSP and HTML filtering.
This article was drafted with AI assistance, then reviewed and curated by Jahia Customer Support engineers before publication.