Skip to content

Commit aa0698c

Browse files
authored
Merge pull request #133 from zmstone/confine-idl-imports-to-cwd
avro_idl: confine IDL import paths
2 parents 618a557 + 3a4ec74 commit aa0698c

5 files changed

Lines changed: 260 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,35 @@ protocol Orders {
307307
Store = avro_schema_store:new([], ["schemas/orders.avdl"]),
308308
```
309309

310+
### Import directory confinement
311+
312+
The default reader confines imports to a "root directory" — by default the
313+
directory of the top-level `.avdl` file passed to the schema store.
314+
Absolute paths and relative paths that escape the root through `..` are
315+
refused with `{error, {import_outside_root, Path}}`. `..` is allowed as
316+
long as the resolved path stays inside the root, so a layout that splits
317+
schemas across sibling directories still works:
318+
319+
```text
320+
schemas/
321+
common/types.avdl
322+
orders/order.avdl %% can import "../common/types.avdl"
323+
```
324+
325+
To use that layout, point `rootdir` at the top of the tree so imports
326+
above the loaded file's own directory can still resolve:
327+
328+
```erlang
329+
Store = avro_schema_store:new(
330+
[], ["schemas/orders/order.avdl"],
331+
[{rootdir, "schemas"}]).
332+
```
333+
334+
Callers that need different resolution semantics (loading from arbitrary
335+
directories or from an in-memory store) can supply their own function
336+
via the `{read_fun, Fun}` option — that bypasses the path checks
337+
entirely. See the next section for an example.
338+
310339
## In-memory schema loading (no filesystem)
311340

312341
For testing or embedded schemas, supply a custom `read_fun` that resolves

‎changelog.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
* 2.11.1
2+
- avro_idl: the default IDL `read_fun` now confines imports to a root
3+
directory. Absolute paths and relative paths that escape the root
4+
through `..` are refused with `{error, {import_outside_root, Path}}`,
5+
but `..` is allowed as long as the resolved path stays under the
6+
root. The root defaults to the top-level caller's `Cwd` and can be
7+
overridden via the new `{rootdir, Dir}` option, supported by
8+
`avro_idl:decode_schema/3`, `avro_idl:new_context/2`,
9+
`avro_idl:str_to_avpr/3`, and the new
10+
`avro_schema_store:new/3` / `import_file/3` / `import_files/3`
11+
entry points. Callers that need different resolution semantics
12+
(loading from arbitrary directories or from an in-memory store)
13+
can still supply their own function via the existing
14+
`{read_fun, Fun}` option, which bypasses path checks.
15+
116
* 2.11.0
217
- Deleted jsone as dependency.
318
When OTP release is 27 or later, the default JSON provider module is `json`, otherwise `jsone`.

‎src/avro_idl.erl‎

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
-type read_fun() :: fun((Cwd :: string(), Path :: string()) ->
1919
{ok, binary()} | {error, term()}).
2020

21-
-record(st, {cwd, read_fun :: read_fun()}).
21+
-record(st, {cwd :: string(),
22+
rootdir :: string(),
23+
read_fun :: read_fun()}).
2224

2325
decode_schema(SchemaStr, Cwd) ->
2426
decode_schema(SchemaStr, Cwd, []).
@@ -40,16 +42,62 @@ decode_schema(SchemaStr, Cwd, Opts) ->
4042
end, Types1),
4143
avro:decode_schema(Types, [{ignore_bad_default_values, true}]).
4244

43-
-spec default_read_fun() -> read_fun().
44-
default_read_fun() ->
45-
fun(Cwd, Path) -> file:read_file(filename:join(Cwd, Path)) end.
45+
%% @doc Default `read_fun' used when one is not supplied via options.
46+
%% Import paths are resolved relative to the importing file's directory
47+
%% (`Cwd'), but the resolved location must stay under `RootDir' — the
48+
%% top-level caller's `Cwd' unless overridden via the `rootdir' option.
49+
%% Absolute paths and relative paths that escape `RootDir' through `..'
50+
%% are refused with `{error, {import_outside_root, Path}}'. `..' within
51+
%% `RootDir' is allowed, so layouts like `orders/order.avdl' importing
52+
%% `../common/types.avdl' work as long as `common/' is also under
53+
%% `RootDir'. Callers that need different resolution semantics can
54+
%% supply their own function via the `read_fun' option.
55+
-spec default_read_fun(RootDir :: string()) -> read_fun().
56+
default_read_fun(RootDir) ->
57+
{ok, RootParts} = normalize(filename:split(RootDir)),
58+
fun(Cwd, Path) ->
59+
case check_path(Cwd, Path, RootParts) of
60+
{ok, Parts} -> file:read_file(filename:join(Parts));
61+
unsafe -> {error, {import_outside_root, Path}}
62+
end
63+
end.
64+
65+
%% @private Resolve `Path' against `Cwd', collapse `.'/`..' segments,
66+
%% and check the result still lies under the root directory identified
67+
%% by `RootParts'. Returns the normalized path parts on success, or
68+
%% `unsafe' if the resolved path escapes the root via `..' or otherwise
69+
%% leaves the rooted tree. Absolute `Path' is accepted only when the
70+
%% root is also absolute and is a prefix of `Path' — otherwise
71+
%% `filename:join/2' substitutes `Path' for `Cwd' and the prefix check
72+
%% rejects it.
73+
check_path(Cwd, Path, RootParts) ->
74+
case normalize(filename:split(filename:join(Cwd, Path))) of
75+
{ok, Parts} ->
76+
case lists:prefix(RootParts, Parts) of
77+
true -> {ok, Parts};
78+
false -> unsafe
79+
end;
80+
escape ->
81+
unsafe
82+
end.
83+
84+
%% @private Collapse `.' and `..' segments. Returns `escape' if `..'
85+
%% would pop past the start of the path, otherwise `{ok, Parts}'.
86+
normalize(Parts) -> normalize(Parts, []).
87+
88+
normalize([], Acc) -> {ok, lists:reverse(Acc)};
89+
normalize(["." | T], Acc) -> normalize(T, Acc);
90+
normalize([".." | T], [_ | AccT]) -> normalize(T, AccT);
91+
normalize([".." | _], []) -> escape;
92+
normalize([P | T], Acc) -> normalize(T, [P | Acc]).
4693

4794
new_context(Cwd) ->
4895
new_context(Cwd, []).
4996

5097
new_context(Cwd, Opts) ->
51-
ReadFun = proplists:get_value(read_fun, Opts, default_read_fun()),
52-
#st{cwd = Cwd, read_fun = ReadFun}.
98+
RootDir = proplists:get_value(rootdir, Opts, Cwd),
99+
ReadFun = proplists:get_value(read_fun, Opts, default_read_fun(RootDir)),
100+
#st{cwd = Cwd, rootdir = RootDir, read_fun = ReadFun}.
53101

54102
str_to_avpr(String, Cwd) ->
55103
str_to_avpr(String, Cwd, []).
@@ -101,11 +149,11 @@ process_imports(Defs, St) ->
101149
[Def]
102150
end, Defs).
103151

104-
load_idl_import(Path, #st{cwd = Cwd, read_fun = ReadFun} = St) ->
152+
load_idl_import(Path, #st{cwd = Cwd, rootdir = Root, read_fun = ReadFun} = St) ->
105153
{ok, Bin} = ReadFun(Cwd, Path),
106154
ImportedCwd = filename:dirname(filename:join(Cwd, Path)),
107155
Avpr = str_to_avpr(binary_to_list(Bin), ImportedCwd,
108-
[{read_fun, ReadFun}]),
156+
[{read_fun, ReadFun}, {rootdir, Root}]),
109157
types_from_avpr(Avpr, St).
110158

111159
load_avpr_import(Path, #st{cwd = Cwd, read_fun = ReadFun} = St) ->

‎src/avro_schema_store.erl‎

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,16 @@
4040
-export([ new/0
4141
, new/1
4242
, new/2
43+
, new/3
4344
, close/1
4445
, is_store/1
4546
]).
4647

4748
%% Import
4849
-export([ import_file/2
50+
, import_file/3
4951
, import_files/2
52+
, import_files/3
5053
, import_schema_json/2
5154
, import_schema_json/3
5255
]).
@@ -97,11 +100,19 @@ new(Options) ->
97100
end
98101
end.
99102

100-
%% @doc Create a new schema store and improt the given schema JSON files.
103+
%% @doc Create a new schema store and import the given schema JSON files.
101104
-spec new([proplists:property()], [filename()]) -> store().
102105
new(Options, Files) ->
106+
new(Options, Files, []).
107+
108+
%% @doc Same as {@link new/2} but forwards `ImportOpts' to
109+
%% {@link import_files/3} — e.g. `[{rootdir, "schemas"}]' when loading
110+
%% `.avdl' files that import from sibling directories via `..'.
111+
-spec new([proplists:property()], [filename()], [proplists:property()]) ->
112+
store().
113+
new(Options, Files, ImportOpts) ->
103114
Store = new(Options),
104-
import_files(Files, Store).
115+
import_files(Files, Store, ImportOpts).
105116

106117
%% @doc Return true if the given arg is a schema store.
107118
-spec is_store(term()) -> boolean().
@@ -120,7 +131,13 @@ to_lookup_fun(Store) ->
120131
%% @doc Import avro JSON files into schema store.
121132
-spec import_files([filename()], store()) -> store().
122133
import_files(Files, Store) ->
123-
lists:foldl(fun(File, S) -> import_file(File, S) end, Store, Files).
134+
import_files(Files, Store, []).
135+
136+
%% @doc Import avro JSON/IDL files into schema store with options.
137+
%% Options are forwarded to {@link import_file/3}.
138+
-spec import_files([filename()], store(), [proplists:property()]) -> store().
139+
import_files(Files, Store, Opts) ->
140+
lists:foldl(fun(File, S) -> import_file(File, S, Opts) end, Store, Files).
124141

125142
%% @doc Import avro JSON or IDL file into schema store.
126143
%% For ".avdl" files, the IDL is compiled and all types are imported.
@@ -134,9 +151,22 @@ import_files(Files, Store) ->
134151
%% @end
135152
-spec import_file(filename(), store()) -> store().
136153
import_file(File, Store) ->
154+
import_file(File, Store, []).
155+
156+
%% @doc Same as {@link import_file/2} but accepts options.
157+
%% Supported options for `.avdl' inputs:
158+
%% <ul>
159+
%% <li>`{rootdir, Dir}' — directory that bounds default import
160+
%% resolution. Imports may use `..' freely within `Dir' but
161+
%% cannot escape it. Defaults to the directory of `File'.</li>
162+
%% <li>`{read_fun, Fun}' — custom reader; bypasses all path checks.</li>
163+
%% </ul>
164+
%% Options are ignored for non-IDL inputs.
165+
-spec import_file(filename(), store(), [proplists:property()]) -> store().
166+
import_file(File, Store, Opts) ->
137167
case filename:extension(File) of
138168
Ext when Ext =:= ".avdl"; Ext =:= <<".avdl">> ->
139-
import_idl_file(File, Store);
169+
import_idl_file(File, Store, Opts);
140170
_ ->
141171
case file:read_file(File) of
142172
{ok, Json} ->
@@ -254,11 +284,11 @@ import_schema_json(AssignedName, Json, Store) ->
254284

255285
%% @private Import an Avro IDL (.avdl) file into the schema store.
256286
%% All named types defined in (or imported by) the protocol are added.
257-
-spec import_idl_file(filename(), store()) -> store().
258-
import_idl_file(File, Store) ->
287+
-spec import_idl_file(filename(), store(), [proplists:property()]) -> store().
288+
import_idl_file(File, Store, Opts) ->
259289
Cwd = filename:dirname(File),
260290
{ok, Bin} = file:read_file(File),
261-
Schema = avro_idl:decode_schema(binary_to_list(Bin), Cwd),
291+
Schema = avro_idl:decode_schema(binary_to_list(Bin), Cwd, Opts),
262292
{_Root, FlatTypes} = avro:flatten_type(Schema),
263293
lists:foldl(fun do_add_type/2, Store, FlatTypes).
264294

‎test/avro_idl_tests.erl‎

Lines changed: 123 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,12 @@ import_schema_test() ->
170170
Types).
171171

172172
import_nested_idl_test() ->
173-
%% subdir/submodule.avdl imports ../foo.avdl (relative to its own dir).
174-
%% Verifies that import paths are resolved relative to the importing file,
175-
%% not relative to the top-level caller's cwd.
173+
%% subdir/submodule.avdl imports ../foo.avdl. The top-level caller's
174+
%% cwd is "test/data" which is also the default rootdir, so the ".."
175+
%% from subdir/ stays inside the rooted tree and the import resolves
176+
%% to test/data/foo.avdl. Also verifies that import paths are resolved
177+
%% relative to the importing file, not the top-level cwd (foo.avdl is
178+
%% not at "test/data/subdir/../foo.avdl" unless we recurse correctly).
176179
Proto = avro_idl:str_to_avpr(
177180
"protocol P { import idl \"subdir/submodule.avdl\"; }",
178181
"test/data"),
@@ -183,6 +186,36 @@ import_nested_idl_test() ->
183186
#{<<"name">> := <<"SubRecord">>, <<"type">> := ?AVRO_RECORD}],
184187
Types).
185188

189+
import_with_rootdir_allows_dotdot_within_tree_test() ->
190+
%% Loading subdir/submodule.avdl directly: its cwd becomes subdir/, so
191+
%% "../foo.avdl" would escape if rootdir defaulted to that. Passing the
192+
%% wider rootdir explicitly lets the import resolve while keeping the
193+
%% confinement boundary.
194+
SubmoduleFile = test_data("subdir/submodule.avdl"),
195+
{ok, Bin} = file:read_file(SubmoduleFile),
196+
RootDir = test_data(""),
197+
Proto = avro_idl:str_to_avpr(
198+
binary_to_list(Bin),
199+
filename:dirname(SubmoduleFile),
200+
[{rootdir, RootDir}]),
201+
#{<<"types">> := Types} = Proto,
202+
?assertMatch(
203+
[#{<<"name">> := <<"FooRecord">>, <<"type">> := ?AVRO_RECORD},
204+
#{<<"name">> := <<"FooEnum">>, <<"type">> := ?AVRO_ENUM},
205+
#{<<"name">> := <<"SubRecord">>, <<"type">> := ?AVRO_RECORD}],
206+
Types).
207+
208+
import_with_rootdir_blocks_escape_test() ->
209+
%% rootdir set to subdir/ — ../foo.avdl escapes the rooted tree and
210+
%% must be refused even though the file exists.
211+
SubmoduleFile = test_data("subdir/submodule.avdl"),
212+
{ok, Bin} = file:read_file(SubmoduleFile),
213+
Cwd = filename:dirname(SubmoduleFile),
214+
?assertError(
215+
{badmatch, {error, {import_outside_root, "../foo.avdl"}}},
216+
avro_idl:str_to_avpr(
217+
binary_to_list(Bin), Cwd, [{rootdir, Cwd}])).
218+
186219
import_with_read_fun_test() ->
187220
%% All schemas kept in memory; no filesystem access.
188221
%% dep.avdl transitively imports transitive.avdl, verifying read_fun
@@ -212,6 +245,88 @@ import_with_read_fun_test() ->
212245
#{<<"name">> := <<"DepRecord">>, <<"type">> := ?AVRO_RECORD}],
213246
Types).
214247

248+
import_outside_root_rejected_test_() ->
249+
%% The default read_fun resolves imports relative to the importing
250+
%% file's directory and refuses paths that point outside it, both
251+
%% absolute paths and relative paths that escape through "..".
252+
AttackPaths = ["/etc/passwd", "../../etc/passwd"],
253+
Variants = ["idl", "protocol", "schema"],
254+
[?_assertError(
255+
{badmatch, {error, {import_outside_root, _}}},
256+
avro_idl:str_to_avpr(
257+
"protocol P { import " ++ Kind ++ " \"" ++ AP ++ "\"; }",
258+
"test/data"))
259+
|| Kind <- Variants, AP <- AttackPaths].
260+
261+
import_absolute_path_under_absolute_root_test() ->
262+
%% An absolute import path is accepted when rootdir is also absolute
263+
%% and the path lies under it.
264+
AbsRoot = test_data(""),
265+
AbsFoo = test_data("foo.avdl"),
266+
Proto = avro_idl:str_to_avpr(
267+
"protocol P { import idl \"" ++ AbsFoo ++ "\"; }",
268+
AbsRoot,
269+
[{rootdir, AbsRoot}]),
270+
#{<<"types">> := Types} = Proto,
271+
?assertMatch(
272+
[#{<<"name">> := <<"FooRecord">>, <<"type">> := ?AVRO_RECORD},
273+
#{<<"name">> := <<"FooEnum">>, <<"type">> := ?AVRO_ENUM}],
274+
Types).
275+
276+
import_absolute_path_outside_absolute_root_test() ->
277+
%% Absolute path with absolute rootdir is rejected when the path is
278+
%% not under the root. Exercises the prefix check in the
279+
%% absolute-vs-absolute case (the existing /etc/passwd test uses a
280+
%% relative root, so the abs-vs-rel mismatch alone makes it fail).
281+
AbsRoot = test_data("subdir"),
282+
AbsFoo = test_data("foo.avdl"),
283+
?assertError(
284+
{badmatch, {error, {import_outside_root, _}}},
285+
avro_idl:str_to_avpr(
286+
"protocol P { import idl \"" ++ AbsFoo ++ "\"; }",
287+
AbsRoot,
288+
[{rootdir, AbsRoot}])).
289+
290+
import_rootdir_with_dotdot_segments_test() ->
291+
%% rootdir with embedded `..' segments is normalized once at
292+
%% setup, so the prefix check still works against canonical paths.
293+
DenormRoot = test_data("subdir/.."),
294+
Proto = avro_idl:str_to_avpr(
295+
"protocol P { import idl \"subdir/submodule.avdl\"; }",
296+
DenormRoot,
297+
[{rootdir, DenormRoot}]),
298+
#{<<"types">> := Types} = Proto,
299+
?assertMatch(
300+
[#{<<"name">> := <<"FooRecord">>, <<"type">> := ?AVRO_RECORD},
301+
#{<<"name">> := <<"FooEnum">>, <<"type">> := ?AVRO_ENUM},
302+
#{<<"name">> := <<"SubRecord">>, <<"type">> := ?AVRO_RECORD}],
303+
Types).
304+
305+
import_dot_segments_collapsed_test() ->
306+
%% `.' segments in the import path are collapsed by normalize/1.
307+
Proto = avro_idl:str_to_avpr(
308+
"protocol P { import idl \"./foo.avdl\"; }",
309+
"test/data"),
310+
#{<<"types">> := Types} = Proto,
311+
?assertMatch(
312+
[#{<<"name">> := <<"FooRecord">>, <<"type">> := ?AVRO_RECORD},
313+
#{<<"name">> := <<"FooEnum">>, <<"type">> := ?AVRO_ENUM}],
314+
Types).
315+
316+
import_outside_root_with_read_fun_override_test() ->
317+
%% The strict default can be bypassed by supplying a custom read_fun;
318+
%% verify the option still takes effect and the default is not applied
319+
%% on top of it.
320+
ReadFun = fun(_Cwd, _Path) ->
321+
{ok, <<"protocol Bypassed { record R { int n; } }">>}
322+
end,
323+
Proto = avro_idl:str_to_avpr(
324+
"protocol P { import idl \"/anywhere/on/disk.avdl\"; }",
325+
"test/data",
326+
[{read_fun, ReadFun}]),
327+
#{<<"types">> := Types} = Proto,
328+
?assertMatch([#{<<"name">> := <<"R">>}], Types).
329+
215330
duplicate_annotation_avpr_test() ->
216331
?assertError(
217332
{duplicate_annotation, "my_decorator", _, _},
@@ -241,10 +356,12 @@ nested_complex_types_avr_test() ->
241356
).
242357

243358
encode_decode_test() ->
244-
%% subdir/submodule.avdl imports ../foo.avdl (idl); verify types from both
245-
%% files can be encoded/decoded after loading via schema store
359+
%% subdir/submodule.avdl imports ../foo.avdl (idl); verify types from
360+
%% both files can be encoded/decoded after loading via schema store.
361+
%% rootdir is the test/data tree so the "../" import resolves inside it.
246362
SubmoduleFile = test_data("subdir/submodule.avdl"),
247-
Store1 = avro_schema_store:new([], [SubmoduleFile]),
363+
Store1 = avro_schema_store:new(
364+
[], [SubmoduleFile], [{rootdir, test_data("")}]),
248365
LookupFun1 = avro_schema_store:to_lookup_fun(Store1),
249366
Encoder1 = avro:make_encoder(LookupFun1, []),
250367
Decoder1 = avro:make_decoder(LookupFun1, []),

0 commit comments

Comments
 (0)