Skip to content

fix: prevent include/extends escaping the configured template directory - #982

Open
BrianWillows wants to merge 1 commit into
twigjs:masterfrom
BrianWillows:fix/include-path-traversal
Open

fix: prevent include/extends escaping the configured template directory#982
BrianWillows wants to merge 1 commit into
twigjs:masterfrom
BrianWillows:fix/include-path-traversal

Conversation

@BrianWillows

Copy link
Copy Markdown

Summary

Twig.path.relativePath() resolves an {% include %} / {% extends %} /
{% embed %} target without checking that the result stays inside the
configured template directory. The resolver walks .. segments and, once the
running path is exhausted, simply pushes them:

} else if (val === '..' && newPath.length > 0 && newPath[newPath.length - 1] !== '..') {
    newPath.pop();
} else {
    newPath.push(val);   // '..' kept -> climbs out of the template root
}

So enough ../ segments escape the views/templates directory and read an
arbitrary file, whose content is inlined into the rendered output
(CWE-22 path traversal → CWE-98 local file inclusion).

Reproduced on 3.0.0 — a template inside views/ containing
{% include "../SECRET.txt" %} rendered the contents of a file placed
outside views/.

This is exploitable wherever the include target is influenced by untrusted
input: dynamic template/partial selection, a template name taken from a
request, or a user-supplied template. Static includes in trusted templates are
unaffected.

For reference, the PHP implementation's FilesystemLoader explicitly refuses
these paths ("Looking outside the configured directories is forbidden"), so
this is a divergence from the upstream security model rather than intended
behaviour.

Fix

When a template directory has been configured (template.base), reject targets
that resolve outside it.

Relative includes within the configured root keep working — including the
repo's own test/templates/include/relative.twig fixture
({% include "../simple.twig" %} from a subfolder), which the first version of
this patch broke and which drove the final approach. Only paths that climb out
of the root are refused.

Verification

  • Full test suite passes: 533 passing, 0 failing (mocha -r should).
  • With base set to a views/ directory:
    • {% include "../SECRET.txt" %} → refused, nothing leaked.
    • {% include "../partial.twig" %} from views/sub/ → still renders.
    • same-directory include → still renders.

Note / question for maintainers

When the include is refused, the thrown Twig.Error currently surfaces through
the async path as a secondary TypeError: Cannot read properties of undefined (reading 'valueOf') rather than cleanly propagating. That's the existing error
plumbing in twig.async.js / twig.core.js rather than something this patch
introduces, but I'm happy to follow up so the rejection reports cleanly — let me
know how you'd prefer it handled.

Found and fixed with AI assistance (Claude). Happy to add a regression test.

Twig.path.relativePath resolves an include/extends/embed target without
checking that the result stays inside the configured template directory.
Enough '../' segments climb out of it, so a target influenced by untrusted
input reads arbitrary files off disk and inlines them into the rendered
output (CWE-22 / CWE-98).

Verified on 3.0.0: a template in views/ doing
{% include "../SECRET.txt" %} inlined a file placed outside views/.

When a template directory is configured (template.base), reject targets
that resolve outside it. Relative includes within that directory - such as
the existing "{% include "../simple.twig" %}" test fixture - are
unaffected; only paths that climb out of the root are refused. The
reference PHP implementation rejects these paths for the same reason
("Looking outside the configured directories is forbidden").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants