Fix "Disallowing path manipulation attempt" from Forge's route tag, and know which of the two causes you hit
Mihai Perdum
Author
11 min readAugust 27, 2026
Key takeaways
END STATE: you can look at any route`` call that throws and say which of the two causes it is, then fix it — verified by a probe you run locally with no deploy.
There is one rule with two origins: a STRING in path position is rejected if it contains a separator, a ?, a # or a doubled dot.
The path check is a SUBSTRING blocklist. 'v1..2' and 'report..final' throw — it is not looking for a bare '..'.
In a QUERY value route percent-encodes for you. In a PATH segment it does NOT: a space passes through raw.
A Route in path position is spliced in UNVALIDATED — which is how you legitimately compose paths, and why assumeTrustedRoute is sharp.
route is the tagged template you wrap every Forge REST path in, and the first time it throws at you the message is not helpful:
text
1Disallowing path manipulation attempt
It's thrown by two completely different mistakes. One is a habit almost everyone brings from ordinary fetch code. The other only shows up when a value you interpolated turns out to contain a character you weren't expecting — which means it works in dev and throws in production, on somebody's real data.
By the end of this you'll be able to look at a failing call and name which one it is, and you'll have a probe that prints exactly which characters route rejects and which it quietly encodes. The second half is the part that's not in the docs.
Note
Prerequisites
Node 18 or later. Verified on v24.15.0.
@forge/api installed. npm i @forge/api@6.4.3 in an empty directory is enough — you do not need a Forge app, a manifest, a deploy or a tunnel.
Everything below runs locally. route builds and validates a path string; it makes no network call, so you can exercise the whole thing offline.
Checked against @forge/api 6.4.3, and the validation source is byte-identical in 8.0.4 — so npm i @forge/api (currently 8.0.4) behaves the same. It has moved within minor releases before (2.19.0 "better protection against path traversal attacks", 2.19.3, 2.20.0), so read your own copy rather than trusting this.
About twenty minutes.
Why one message covers two bugs
route is a tagged template, and that's the whole key. It sees the literal parts of your template and the interpolated values as two separate things, and it treats them very differently: the literal text is your path, and the interpolated values are untrusted data to be checked and encoded.
So when you write this, route sees the path /wiki/api/v2/pages/ plus one value:
javascript
1route`/wiki/api/v2/pages/${pageId}`
And when you write this, it sees an empty path and one value that happens to contain slashes:
From route's side those are unrecognisably different. The second one looks like somebody handed it a data value stuffed with path separators, which is precisely the injection it exists to stop. It cannot tell that you built that string yourself two lines earlier.
1
Install the package and reproduce both failures in one file
no app, no deploy, no manifest.
2
Identify which cause you hit by looking at where your slashes live
in the literal, or inside an interpolated value.
3
Move the path into the template literal so only the variable parts are interpolated.
4
Encode any path segment whose value could contain a structural character.
5
Map what route actually rejects and what it silently encodes, by running the character probe.
6
Guard the fix with a test that fails if someone reintroduces the pre-built path.
Step 1 — Reproduce both failures in one file
bash
1mkdir route-demo &&cd route-demo &&npm init -y>/dev/null &&npm i @forge/api
Save this as route-probe.mjs:
javascript
1import{ route }from"@forge/api";23constshow=(label, fn)=>{4try{console.log(` OK ${label.padEnd(32)}${fn().value}`);}5catch(e){console.log(` THROW ${label.padEnd(32)}${e.message.split(".")[0]}`);}6};78const pageId ="123456";9const prebuilt =`/wiki/api/v2/pages/${pageId}`;10const dirty ="1/2";// an id that is not what you think it is11const title ="Q3/Q4 planning";// a slash in a QUERY value1213console.log("BROKEN — the two cases that throw");14show("whole path interpolated",()=> route`${prebuilt}`);15show("slash in a path segment",()=> route`/wiki/api/v2/pages/${dirty}`);1617console.log("\nFINE — these do not throw, despite the slash");18show("slash in a query value",()=> route`/wiki/api/v2/search?title=${title}`);1920console.log("\nFIXED");21show("path in the literal",()=> route`/wiki/api/v2/pages/${pageId}`);22show("segment encoded",()=> route`/wiki/api/v2/pages/${encodeURIComponent(dirty)}`);
Note .value on the result. route returns an object, so interpolating it into a string — `${r}` or String(r) — gives you [object Object]. console.log(r) on its own is fine and prints ReadonlyRoute { value_: '/wiki/api/v2/pages/123456' }. The rendered path is on .value, which is public: out/safeUrl.d.ts declares readonly value: string.
How you know it worked. Run it:
bash
1node route-probe.mjs
Two throws, three passes. This is the real output:
text
1BROKEN — the two cases that throw
2 THROW whole path interpolated Disallowing path manipulation attempt
3 THROW slash in a path segment Disallowing path manipulation attempt
45FINE — these do not throw, despite the slash
6 OK slash in a query value /wiki/api/v2/search?title=Q3%2FQ4%20planning
78FIXED
9 OK path in the literal /wiki/api/v2/pages/123456
10 OK segment encoded /wiki/api/v2/pages/1%2F2
Look at the third line. The same slash that throws in a path segment is accepted in a query value and percent-encoded for you. That asymmetry is Step 5, and it's the thing worth taking away from this.
If you got no output at all, check "type": "module" is in your package.json or that the file ends in .mjs.
Step 2 — Identify which cause you hit
There's really one rule — a string in path position is rejected if it contains a separator, a ?, a #, or a doubled dot — but it reaches you from two different directions, and the fix differs. One question settles which: where are the slashes?
If your path separators are inside an interpolated value — a variable holding a whole URL, a path you assembled with +, a base constant joined to a suffix, anything built before the route call — that's cause one. Go to Step 3.
If the literal contains the path and the slashes, and it still throws, then one of your interpolated values contains a structural character at runtime. That's cause two, and it's the harder one because the value is usually fine in your test data. Go to Step 4.
How you know which you have: print the interpolated values immediately before the call.
You should see the variable holding your path segments, not a whole path. If one value prints as "/wiki/api/v2/pages/123456" you have cause one; if a value prints as "1/2" or "../admin" you have cause two.
Step 3 — Move the path into the template literal
The fix is mechanical: the literal carries every slash, and interpolation carries only the variable pieces.
javascript
1importapi,{ route }from"@forge/api";23// wrong — route sees one value full of separators4const url =`/wiki/api/v2/pages/${pageId}`;5await api.asApp().requestConfluence(route`${url}`);67// right — route sees a path plus one value8await api.asApp().requestConfluence(route`/wiki/api/v2/pages/${pageId}`);
This bites when you've been factoring out constants, which normally is good practice:
javascript
1constBASE="/wiki/api/v2";2route`${BASE}/pages/${pageId}`;// throws — BASE is a STRING
You can keep the constant. It just has to be a Route rather than a string:
javascript
1constBASE= route`/wiki/api/v2`;2route`${BASE}/pages/${pageId}`;// fine
This is supported and has been since 2.2.0 — the changelog entry reads "Routes can be partially-constructed using other Routes". The reason it works is worth knowing, because it's the actual rule: in path position route checks a string and splices a Route straight through untouched. The relevant line in out/safeUrl.js is the first thing the path branch does:
So the guard is about provenance, not characters. A Route is something you built from literals, so it's trusted. A string is data, so it's checked. That also tells you what assumeTrustedRoute — exported from the same package — really is: a way to hand route a value it will not check at all. It has legitimate uses and it will happily carry a traversal into your path, so treat it the way you'd treat dangerouslySetInnerHTML.
How you know it worked: re-run the probe. The path in the literal line should print /wiki/api/v2/pages/123456 rather than throwing.
Step 4 — Encode a segment that could carry a structural character
Cause two is the one that reaches production, because it depends on data.
Any id you didn't generate yourself can contain something structural. A user-supplied key, a title used as a slug, an external system's identifier, anything round-tripped through a URL already. encodeURIComponent turns those into a single safe segment:
It does not fix every case, and this is the one that will waste your afternoon.encodeURIComponent leaves dots alone — they're unreserved characters — so it is a complete no-op against the doubled-dot rule:
You apply the documented fix, run it again, and get the identical exception. If your value can contain a doubled dot, you have to deal with that separately: normalise it, reject it, or replace it before encoding. Which of those is right depends on whether a doubled dot is legitimate data for you, and that is a decision the library cannot make for you.
Be deliberate about where you apply it. In a path segment it's doing real work — it converts / to %2F so the value stays one segment. In a query value it's redundant, because route already encodes those, and applying it yourself gets you double-encoding: your %2F becomes %252F and the API sees a literal percent-two-F.
That double-encoding bug is worse than the exception you started with. The exception is loud and stops you; double-encoding returns a clean 200 with the wrong results.
You should see /wiki/api/v2/pages/1%2F2 then /wiki/api/v2/search?q=a%2Fb. One %2F in each, never %252F. If you see %252F, you've encoded a query value that route was already going to encode.
Step 5 — Map what route rejects and what it encodes
This is the step that pays for the whole exercise, and none of it is in the error message.
How you know it worked. Run it again — you'll see the Step 1 output first, then:
bash
1node route-probe.mjs
Four throws in the path block, zero in the query block. Real output:
text
1PATH SEGMENT
2 OK space /wiki/api/v2/pages/a b
3 THROW slash Disallowing path manipulation attempt
4 THROW backslash Disallowing path manipulation attempt
5 THROW dot-dot Disallowing path manipulation attempt
6 THROW embedded dots Disallowing path manipulation attempt
7 OK percent /wiki/api/v2/pages/a%b
8 THROW question Disallowing path manipulation attempt
9 THROW hash Disallowing path manipulation attempt
10 OK ampersand /wiki/api/v2/pages/a&b
11 OK plain /wiki/api/v2/pages/abc
1213QUERY VALUE
14 OK space /wiki/api/v2/search?q=a%20b
15 OK slash /wiki/api/v2/search?q=a%2Fb
16 OK dot-dot /wiki/api/v2/search?q=..
17 OK question /wiki/api/v2/search?q=a%3Fb
18 OK hash /wiki/api/v2/search?q=a%23b
19 OK ampersand /wiki/api/v2/search?q=a%26b
Read those two blocks against each other, because they behave nothing alike — and then read the source, which is fifteen lines and settles every question the probe leaves open. It's in node_modules/@forge/api/out/safeUrl.js:
Three things fall out of that, and I got all three wrong by probing instead of reading.
It's includes, not equality. The doubled-dot rule is a substring match, so it fires anywhere in the value, not just when the value is... These all throw:
text
1 THROW v1..2
2 THROW report..final
3 OK 1.2.3
That is the "works in dev, throws in production on real data" case in its purest form. A version string, a filename, an ellipsis a user typed — none of them are traversal attempts and all of them are rejected.
Backslash is in there too.DIRECTORY_PATH is ['/', '\'], so it's five single characters, not four: /, \, ?, #. My probe never tested a backslash and so never saw it.
It's a blocklist. Seven literal spellings of a doubled dot, three separator characters, matched by substring. Blocklists enumerate the bad things somebody thought of, which means the honest way to hold this is as defence in depth rather than as sanitisation. Validate your own inputs against what they're supposed to look like; don't let route be the only thing standing between user data and your path.
In a query value none of this applies — route rejects nothing and encodes everything for interpolated strings and numbers: space to %20, slash to %2F, hash to %23. (Two types skip that path: a URLSearchParams is rendered with its own toString(), so spaces become +; and a Route, whose whole value gets encoded, separators included.)
In a path segment it encodes nothing. That's the asymmetry that matters. The characters that get through untouched include &, ;, =, +, :, @, [, ] and — the one to care about — %, which means a caller-supplied percent sequence lands in your path verbatim and gets decoded downstream. A raw space gets through too, though any URL parser normalises that one before it reaches the wire, so it's the eye-catching example rather than the dangerous one.
So the exception is a structural guard on provenance, not an escaping layer. That's why Step 4 is encodeURIComponent on path segments rather than "trust route to handle it".
Step 6 — Guard the fix with a test
The pre-built path habit comes back, because it reads more naturally and because the next person hasn't hit this yet. One test stops it. Save it as route.test.mjs:
javascript
1importassertfrom"node:assert/strict";2import{ route }from"@forge/api";34constthrows=(fn)=>{try{fn();returnfalse;}catch{returntrue;}};56assert.equal(throws(()=> route`${"/wiki/api/v2/pages/1"}`),true,7"a pre-built path containing separators should still be rejected");8assert.equal(route`/wiki/api/v2/pages/${"1"}`.value,"/wiki/api/v2/pages/1",9"the literal form should build the path");10assert.equal(route`/wiki/api/v2/pages/${encodeURIComponent("1/2")}`.value,11"/wiki/api/v2/pages/1%2F2","a structural char in a segment must be encoded");12console.log("route guards: ok");
How you know it worked:node route.test.mjs prints route guards: ok and exits 0. Then break it deliberately — change the third assertion's expected value to 1/2 — and confirm it fails. A test you have never seen fail is not yet a test.
Point it at your own call sites rather than these toy paths, and it becomes a regression guard for the specific endpoints you use.
[[takeaways]] You can now tell the two origins apart on sight. Separators inside an interpolated value means you built the path before calling route — move it into the literal, or make the constant a Route. Separators in the literal with a runtime throw means an interpolated segment carried one of / \ ? # or a doubled dot anywhere in it, and the fix is encodeURIComponent — except for the doubled dot, which it does not touch.
You also have the real boundary, which the error message never tells you and the probe alone can't give you: a substring blocklist of seven doubled-dot spellings and three separators, applied to strings in path position and to nothing else. A Route in path position is trusted and spliced in unchecked, which is what makes composition work.
The habit worth keeping is narrower than "map the boundary", because I tried that and it wasn't enough. My first version of this article was written entirely by probing — feed it characters, write down what throws. That produced a confident, tidy, wrong rule: four characters, and .. only when the value is exactly ... Reading the fifteen lines of safeUrl.js corrected all of it in about a minute, including a rejected character my probe never thought to try.
Probing samples a boundary. It cannot tell you the shape of the thing you didn't test, and it will hand you a clean-looking rule anyway. When the source is sitting in node_modules, read it — the map you infer from outside is a hypothesis, and the fifteen lines are the answer.