DEV Community

Yusuf İhsan Görgel
Yusuf İhsan Görgel

Posted on

A route could write index.html outside the output directory: a path traversal fix in flutter_prerender 0.3.2

flutter_prerender is a small CLI I maintain on pub.dev. Point it at a flutter build web output and it loads each route in headless Chrome, reads the text out of Flutter's accessibility tree, and writes one static HTML document per route, so crawlers that do not run JavaScript get real content instead of an empty page. Each route is written to <out>/<route>/index.html.

Version 0.3.2, published today, fixes a bug in the step that turns a route into that path. A crafted route could make the writer create index.html outside the output directory, in one case at an absolute path with the output directory dropped entirely. That is an arbitrary file write. It was reachable from a plain routes file or a YAML config, not only from --crawl, and it had been present since 0.1.0.

From a route to a file path

Every route, wherever it comes from, goes through one function, normalizeRoute, and then the writer. The writer does, in effect:

  1. strip the leading slash from the route to get a relative path,
  2. join it onto the output directory with path.join,
  3. create that directory,
  4. write index.html into it.

Routes come from three places: a routes file, a YAML config, or same-origin links discovered with --crawl. All three pass through normalizeRoute and then the writer, so anything normalizeRoute fails to catch reaches the write step.

Before 0.3.2, normalizeRoute only made sure a route began with a leading slash. It prepended one when a route did not have it, and otherwise left the route alone.

Where the routes escaped

A .. segment. Passing ../secret, normalizeRoute prepended a slash and returned /../secret. The writer stripped the leading slash back to ../secret, and path.join(outDir, '../secret') climbs one directory above outDir, so index.html is written outside the directory you chose.

A leading //. Passing //etc/passwd, the route already began with a slash, so normalizeRoute returned it unchanged. The writer stripped one leading slash, leaving /etc/passwd, which is an absolute path. path.join(outDir, '/etc/passwd') returns /etc/passwd: path.join discards everything before an absolute segment, so outDir is dropped and the write lands at an absolute location.

Three routes flowing through strip-leading-slash then path.join(outDir, relative): a normal route lands inside the output directory, a dot-dot route climbs one level above it, and a double-slash route resolves to an absolute path that discards the output directory

Why a leading-slash check let them through

A leading-slash check looks at the front of the string. /../secret starts with exactly one slash and passes. //etc/passwd starts with a slash and passes. Neither the interior .. nor the second leading slash is something a leading-slash rule examines. Both problems live past the first character.

The fix in 0.3.2

Three parts: reject the dangerous shapes for explicit routes, skip them for crawled links, and check containment at the write boundary.

1. Reject the dangerous shapes in normalizeRoute

For routes the user wrote down, in a routes file or a YAML config, a bad shape is a mistake to fix, so normalizeRoute now throws ConfigException. This code runs at the end of the function, after the leading slash has been normalized into a local route:

  // A route is written to disk as a path under the output directory, so it
  // must not be able to climb out of it. A .. segment would write above the
  // output directory; a leading // is an absolute path that path.join would
  // honour, discarding the output directory entirely.
  if (route.startsWith('//')) {
    throw ConfigException('Route must not start with "//": "$value"$where');
  }
  if (route.split('/').contains('..')) {
    throw ConfigException('Route must not contain a ".." segment: "$value"$where');
  }
  return route;
Enter fullscreen mode Exit fullscreen mode

The message quotes the route and adds the source location when it came from a numbered line in a file.

2. Skip a bad crawled link instead of aborting the crawl

Discovered links are different. One page on the site linking to an odd href should not stop the whole crawl, so the crawl path, sameOriginRoute, catches the ConfigException and skips that link, the same as it skips any other link it cannot turn into a route. It returns null for a link it will not follow.

A crawled link reaches normalizeRoute after one extra step. It is parsed with Uri first, and Uri applies RFC 3986 dot-segment removal. An absolute link like /../secret is collapsed by Uri to /secret before normalizeRoute ever sees it, because .. cannot climb above the root of an absolute path. Only a relative traversal like ../secret keeps its .., reaches normalizeRoute, throws, and gets skipped.

3. A containment check at the write boundary

The last part does not rely on the route being well formed. Before writing, the writer resolves the full target path and refuses to write unless the target is the output directory or inside it. Here p is package:path/path.dart:

  final target = p.normalize(p.join(outDir, relative));
  if (target != outDir && !p.isWithin(outDir, target)) {
    throw ConfigException(
      'Refusing to write route "$route" outside the output directory.',
    );
  }
Enter fullscreen mode Exit fullscreen mode

p.normalize resolves any .. in the joined path first, so the comparison is on the real location, and p.isWithin(outDir, target) is the containment test. This is defense in depth: even if some future route source skipped normalizeRoute, nothing gets written outside --out.

What the functions do now

Both are exported from package:flutter_prerender/flutter_prerender.dart. normalizeRoute(String raw, {int? lineNumber}) returns the normalized route or throws ConfigException. sameOriginRoute(String href, {String? origin}) returns a route string, or null for a link it will not follow. ConfigException is an Exception with a String message.

The behavior on 0.3.2, as a test:

import 'package:flutter_prerender/flutter_prerender.dart';
import 'package:test/test.dart';

void main() {
  test('explicit routes are validated', () {
    expect(normalizeRoute('/about'), '/about');
    expect(() => normalizeRoute('../secret'), throwsA(isA<ConfigException>()));
    expect(() => normalizeRoute('//etc/passwd'), throwsA(isA<ConfigException>()));
  });

  test('crawled links are filtered', () {
    expect(sameOriginRoute('../secret'), isNull);   // relative traversal, skipped
    expect(sameOriginRoute('/../secret'), '/secret'); // absolute, Uri collapses the ..
    expect(sameOriginRoute('about'), '/about');
  });
}
Enter fullscreen mode Exit fullscreen mode

What still works: safe absolute links

The fix targets two specific shapes, so ordinary routes are unaffected, including a safe absolute link that happens to contain ... A link like /../about is absolute, so Uri collapses it to /about before normalizeRoute sees it, the same rule that turns /../secret into /secret. Since .. cannot climb above the root of an absolute path, the link stays a normal in-site route. What gets rejected is a relative ../secret and a literal leading //.

When external input becomes a path

Turning external input into a filesystem path has two traps a leading-slash check does not catch.

A .. segment climbs out of the directory you meant to confine writes to. And path.join(base, x) does not always return something under base: if x is absolute, join returns x and base is gone.

The fix is two layers that do not depend on each other. Validate the logical route by rejecting .. segments and a leading //, then check containment at the write boundary with p.isWithin, so the write cannot land outside the directory no matter how the path was assembled. This is the same shape as classic web path traversal, here in a Dart CLI that writes files.

flutter_prerender 0.3.2 is on pub.dev.

Top comments (0)