Controlling Access to Configuration Sources

An include directive turns text from a configuration file into a request for another source. Resolving that request and trusting its result are deliberately separate decisions. This page explains how the parser checks every source before opening it, how to configure the built-in filesystem boundary, and when an application should extend or replace that policy.

Access Checks for Root and Included Sources

The AccessCheck sits between source resolution and source I/O. The parser calls its check() method for the initial document and for every source returned by an include resolver. A rejected source is therefore never opened by the parser.

Each call receives an AccessSources value with three identifiers:

source

The source the parser is about to open.

parent

The document whose @include directive requested this source, or a null pointer for the initial document.

root

The initial document of the complete parse operation.

This context lets a policy distinguish the entry point from an include and evaluate a request against either its direct parent or the original trust root. The identifiers are lightweight descriptions; an access check should inspect their source names and paths without opening the sources itself.

Default File Include Access Restrictions

Every new Parser uses a FileAccessCheck. Its default policy accepts file includes beside their parent document and below that directory, while rejecting paths that escape to a parent or unrelated directory. Before comparing paths, it resolves them physically, so .. elements and symbolic links cannot disguise where an included file is located.

The default also rejects included files larger than 100 MB. It does not require an .elcl suffix, and it leaves non-file sources to an application-specific policy unless you enable the file-only restriction.

Configuring File Access

Create a shared file check with FileAccessCheck::create(), change only the features required by your application, and install it with Parser::setAccessCheck() before parsing. The available FileAccessCheck::Feature values are:

File access features

Feature

Default

Effect

SameDirectory

Enabled

Accepts an included file in the directory of the document that includes it.

Subdirectories

Enabled

Accepts included files below the including document’s directory.

AnyDirectory

Disabled

Removes the directory-containment restriction. Size and suffix checks still apply when enabled.

OnlyFileSources

Disabled

Rejects every source whose source name is not file.

LimitSize

Enabled

Rejects an included file larger than 100 MB.

RequireSuffix

Disabled

Requires included file paths to end in .elcl, compared without ASCII case sensitivity.

At least one directory feature must permit an included file. Disabling SameDirectory and Subdirectories without enabling AnyDirectory blocks all file includes. Enabling AnyDirectory is appropriate only when another boundary, such as an exact allowlist, already controls the paths.

The next demo keeps the safe defaults, adds the suffix requirement, and layers a small application rule on top. The generated theme uses Polish station names as configuration data; the policy itself remains independent of that data.

conf/AccessChecks/ConfigureFileAccess.cpp

/// Add an application rule to the configurable file access policy.
///
/// `FileAccessCheck` provides the filesystem boundary, size limit, and optional suffix check. A derived check can call
/// the base implementation first and then enforce a rule that is specific to the application. The configured shared
/// instance is installed with `Parser::setAccessCheck()` before parsing starts.
class StationFileAccessCheck final : public el::conf::FileAccessCheck {
public: // implement `AccessCheck`
    auto check(const el::conf::AccessSources &sources) -> el::conf::AccessCheckResult override {
        const auto result = FileAccessCheck::check(sources);
        if (sources.parent != nullptr && el::Path{sources.source->path()}.name().startsWith("draft-"_el)) {
            throw el::conf::ConfError{
                el::conf::ConfErrorCategory::Access,
                "Draft station data cannot be included in an operational configuration."_el,
                el::Path{sources.source->path()}};
        }
        return result;
    }
};

/// Configure and install a file access check before parsing a document.
void configureFileAccess() {
    auto temporaryOptions = el::PathTempDirectoryOptions{};
    temporaryOptions.setPrefix("stacja-badawcza-"_el).setRandomLength(el::CpCount{8U});
    const auto temporary =
        el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow(temporaryOptions);
    const auto root = temporary->path();
    (root / "station.elcl"_el)
        .content()
        .writeTextOrThrow(
            "[station]\n"
            "name: \"Stacja Polarna Aurora\"\n"_el);
    const auto mainPath = root / "main.elcl"_el;
    mainPath.content().writeTextOrThrow("@include: \"station.elcl\"\n"_el);

    // Keep the safe directory and size defaults, and require the usual ELCL suffix as well.
    const auto accessCheck = std::make_shared<StationFileAccessCheck>();
    accessCheck->enable(el::conf::FileAccessCheck::RequireSuffix);

    auto parser = el::conf::Parser{};
    parser.setAccessCheck(accessCheck);
    const auto document = parser.parseFileOrThrow(mainPath);
    el::io::printLine("Station: "_el, document->getTextOrThrow("station.name"_el));
}

Output

Station: Stacja Polarna Aurora

Extending the File Policy

Deriving from FileAccessCheck is the best fit when local files still form the underlying trust boundary. Call the base check() first, as StationFileAccessCheck does in the demo, and apply the application rule only after the standard containment, size, and suffix checks have succeeded. This preserves the less visible protections around canonical paths and filesystem errors.

Throwing ConfError with the Access category gives the caller a useful reason for the denial. Returning AccessCheckResult::Denied is suitable when the generic Access denied to source message is sufficient. In both cases, the parser adds the location of the include directive to the error.

Writing a Complete Access Policy

A policy should implement AccessCheck directly when the application’s trust model is not based on directory containment. Typical examples are an exact file allowlist, signed resource identifiers, or sources served by an application-owned repository.

The following implementation accepts only a fixed set of physical file paths. It validates both the source protocol and the parser-provided context, canonicalizes its allowlist once, and checks the initial document as well as its include.

conf/AccessChecks/CustomAccessCheck.cpp

/// Restrict parsing to an exact list of approved configuration files.
///
/// A fully custom `AccessCheck` is useful when an application already has a complete source policy. The parser calls
/// `check()` for the root and every included source before opening it. This implementation canonicalizes file paths and
/// grants access only when the requested path is present in its allowlist.
class ApprovedStationSources final : public el::conf::AccessCheck {
public:
    explicit ApprovedStationSources(const el::PathList &approvedPaths) {
        for (const auto &path : approvedPaths) {
            _approvedPaths += path.resolveOrThrow(el::PathResolveMode::Physical);
        }
    }

public: // implement `AccessCheck`
    auto check(const el::conf::AccessSources &sources) -> el::conf::AccessCheckResult override {
        if (sources.source == nullptr || sources.root == nullptr || sources.source->name() != "file"_el) {
            return el::conf::AccessCheckResult::Denied;
        }
        auto requestedPath = el::Path{sources.source->path()};
        try {
            requestedPath = requestedPath.resolveOrThrow(el::PathResolveMode::Physical);
        } catch (const el::PathError &) {
            throw el::conf::ConfError{
                el::conf::ConfErrorCategory::Access,
                "Configuration Source Access Denied"_el,
                "The configuration source path cannot be resolved."_el,
                requestedPath,
                std::current_exception()};
        }
        for (const auto &approvedPath : _approvedPaths) {
            if (requestedPath == approvedPath) {
                return el::conf::AccessCheckResult::Granted;
            }
        }
        throw el::conf::ConfError{
            el::conf::ConfErrorCategory::Access,
            "The configuration source is not in the station allowlist."_el,
            requestedPath};
    }

private:
    el::PathList _approvedPaths;
};

/// Install a custom access check that implements an application-owned trust boundary.
void customAccessCheck() {
    auto temporaryOptions = el::PathTempDirectoryOptions{};
    temporaryOptions.setPrefix("stacja-badawcza-"_el).setRandomLength(el::CpCount{8U});
    const auto temporary =
        el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow(temporaryOptions);
    const auto root = temporary->path();
    const auto stationPath = root / "station.elcl"_el;
    stationPath.content().writeTextOrThrow(
        "[station]\n"
        "name: \"Obserwatorium Północne\"\n"_el);
    const auto mainPath = root / "main.elcl"_el;
    mainPath.content().writeTextOrThrow("@include: \"station.elcl\"\n"_el);

    const auto approvedPaths = el::PathList{mainPath, stationPath};

    auto parser = el::conf::Parser{};
    parser.setAccessCheck(std::make_shared<ApprovedStationSources>(approvedPaths));
    const auto document = parser.parseFileOrThrow(mainPath);
    el::io::printLine("Approved station: "_el, document->getTextOrThrow("station.name"_el));
}

Output

Approved station: Obserwatorium Północne

For a non-file source, use the same pattern with stable logical identifiers supplied by the matching source resolver. Treat source, parent, and root as untrusted input: check for null identifiers where the contract permits them, recognize only source names your policy understands, and make every default branch deny access. Avoid reproducing filesystem containment with text-prefix comparisons, because path separators, symbolic links, case rules, and parent elements make those comparisons unreliable.

Disabling Includes

An application that accepts only self-contained documents can pass a null source resolver to Parser::setSourceResolver(). This disables @include processing altogether and leaves the access check responsible only for the initial source. Applications that enable source resolution should always keep an explicit access boundary in place.

Complete demo sources

conf/AccessChecks/ConfigureFileAccess.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/all_path.hpp>
#include <erbsland/conf/AccessCheckResult.hpp>
#include <erbsland/conf/AccessSources.hpp>
#include <erbsland/conf/ConfError.hpp>
#include <erbsland/conf/ConfErrorCategory.hpp>
#include <erbsland/conf/FileAccessCheck.hpp>
#include <erbsland/conf/Parser.hpp>
#include <erbsland/CpCount.hpp>
#include <erbsland/Path.hpp>
#include <erbsland/PathContent.hpp>
#include <erbsland/PathOperations.hpp>
#include <erbsland/PathTempDirectoryOptions.hpp>
#include <erbsland/StandardStreams.hpp>

namespace demo {

/// Add an application rule to the configurable file access policy.
///
/// `FileAccessCheck` provides the filesystem boundary, size limit, and optional suffix check. A derived check can call
/// the base implementation first and then enforce a rule that is specific to the application. The configured shared
/// instance is installed with `Parser::setAccessCheck()` before parsing starts.
class StationFileAccessCheck final : public el::conf::FileAccessCheck {
public: // implement `AccessCheck`
    auto check(const el::conf::AccessSources &sources) -> el::conf::AccessCheckResult override {
        const auto result = FileAccessCheck::check(sources);
        if (sources.parent != nullptr && el::Path{sources.source->path()}.name().startsWith("draft-"_el)) {
            throw el::conf::ConfError{
                el::conf::ConfErrorCategory::Access,
                "Draft station data cannot be included in an operational configuration."_el,
                el::Path{sources.source->path()}};
        }
        return result;
    }
};

/// Configure and install a file access check before parsing a document.
void configureFileAccess() {
    auto temporaryOptions = el::PathTempDirectoryOptions{};
    temporaryOptions.setPrefix("stacja-badawcza-"_el).setRandomLength(el::CpCount{8U});
    const auto temporary =
        el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow(temporaryOptions);
    const auto root = temporary->path();
    (root / "station.elcl"_el)
        .content()
        .writeTextOrThrow(
            "[station]\n"
            "name: \"Stacja Polarna Aurora\"\n"_el);
    const auto mainPath = root / "main.elcl"_el;
    mainPath.content().writeTextOrThrow("@include: \"station.elcl\"\n"_el);

    // Keep the safe directory and size defaults, and require the usual ELCL suffix as well.
    const auto accessCheck = std::make_shared<StationFileAccessCheck>();
    accessCheck->enable(el::conf::FileAccessCheck::RequireSuffix);

    auto parser = el::conf::Parser{};
    parser.setAccessCheck(accessCheck);
    const auto document = parser.parseFileOrThrow(mainPath);
    el::io::printLine("Station: "_el, document->getTextOrThrow("station.name"_el));
}

}

conf/AccessChecks/CustomAccessCheck.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/all_path.hpp>
#include <erbsland/conf/AccessCheck.hpp>
#include <erbsland/conf/AccessCheckResult.hpp>
#include <erbsland/conf/AccessSources.hpp>
#include <erbsland/conf/ConfError.hpp>
#include <erbsland/conf/ConfErrorCategory.hpp>
#include <erbsland/conf/Parser.hpp>
#include <erbsland/CpCount.hpp>
#include <erbsland/Path.hpp>
#include <erbsland/PathContent.hpp>
#include <erbsland/PathError.hpp>
#include <erbsland/PathOperations.hpp>
#include <erbsland/PathResolveMode.hpp>
#include <erbsland/PathTempDirectoryOptions.hpp>
#include <erbsland/StandardStreams.hpp>

#include <exception>

namespace demo {

/// Restrict parsing to an exact list of approved configuration files.
///
/// A fully custom `AccessCheck` is useful when an application already has a complete source policy. The parser calls
/// `check()` for the root and every included source before opening it. This implementation canonicalizes file paths and
/// grants access only when the requested path is present in its allowlist.
class ApprovedStationSources final : public el::conf::AccessCheck {
public:
    explicit ApprovedStationSources(const el::PathList &approvedPaths) {
        for (const auto &path : approvedPaths) {
            _approvedPaths += path.resolveOrThrow(el::PathResolveMode::Physical);
        }
    }

public: // implement `AccessCheck`
    auto check(const el::conf::AccessSources &sources) -> el::conf::AccessCheckResult override {
        if (sources.source == nullptr || sources.root == nullptr || sources.source->name() != "file"_el) {
            return el::conf::AccessCheckResult::Denied;
        }
        auto requestedPath = el::Path{sources.source->path()};
        try {
            requestedPath = requestedPath.resolveOrThrow(el::PathResolveMode::Physical);
        } catch (const el::PathError &) {
            throw el::conf::ConfError{
                el::conf::ConfErrorCategory::Access,
                "Configuration Source Access Denied"_el,
                "The configuration source path cannot be resolved."_el,
                requestedPath,
                std::current_exception()};
        }
        for (const auto &approvedPath : _approvedPaths) {
            if (requestedPath == approvedPath) {
                return el::conf::AccessCheckResult::Granted;
            }
        }
        throw el::conf::ConfError{
            el::conf::ConfErrorCategory::Access,
            "The configuration source is not in the station allowlist."_el,
            requestedPath};
    }

private:
    el::PathList _approvedPaths;
};

/// Install a custom access check that implements an application-owned trust boundary.
void customAccessCheck() {
    auto temporaryOptions = el::PathTempDirectoryOptions{};
    temporaryOptions.setPrefix("stacja-badawcza-"_el).setRandomLength(el::CpCount{8U});
    const auto temporary =
        el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow(temporaryOptions);
    const auto root = temporary->path();
    const auto stationPath = root / "station.elcl"_el;
    stationPath.content().writeTextOrThrow(
        "[station]\n"
        "name: \"Obserwatorium Północne\"\n"_el);
    const auto mainPath = root / "main.elcl"_el;
    mainPath.content().writeTextOrThrow("@include: \"station.elcl\"\n"_el);

    const auto approvedPaths = el::PathList{mainPath, stationPath};

    auto parser = el::conf::Parser{};
    parser.setAccessCheck(std::make_shared<ApprovedStationSources>(approvedPaths));
    const auto document = parser.parseFileOrThrow(mainPath);
    el::io::printLine("Approved station: "_el, document->getTextOrThrow("station.name"_el));
}

}