Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions internal/documentation/docs/pages/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,26 @@ The current version of the schema can be found here: https://ui5.github.io/cli/s
The schema is also part of the [JSON Schema Store Catalog](http://schemastore.org/json/) which is used by the [YAML Language Server](https://github.com/redhat-developer/yaml-language-server).
See the list of [clients](https://github.com/redhat-developer/yaml-language-server/blob/main/README.md#clients) to find extensions for various IDEs and editors.

## Input Limits

Project loading limits the size and nesting of configuration files before parsing or validation can consume excessive resources. Set the following environment variables before starting UI5 CLI to override the defaults. Values must contain decimal digits and represent positive safe integers. Invalid values produce `ERR_CONFIG_INVALID_LIMIT`.

| Environment Variable | Default | Scope |
| --- | --- | --- |
| `UI5_CONFIG_MAX_BYTES` | 1048576 bytes (1 MiB) | One `ui5.yaml` file, including all YAML documents in that file |
| `UI5_MANIFEST_MAX_BYTES` | 4194304 bytes (4 MiB) | Each `manifest.json`, `manifest.appdescr_variant`, or `.ui5/build-manifest.json` read during project loading |
| `UI5_CONFIG_MAX_DEPTH` | 100 | YAML parser nesting depth |
| `UI5_JSON_MAX_DEPTH` | 100 | Nested objects and arrays in JSON read through the project JSON parser |
| `UI5_CONFIG_MAX_COLLECTION` | 10000 | Entries in any one array or properties in any one object in each YAML document or the `project` configuration from a build manifest |

Byte limits use file size metadata before content is read. The JSON depth check runs before `JSON.parse`, counts the root object or array as depth 1, and ignores brackets inside strings. `UI5_CONFIG_MAX_DEPTH` sets the `js-yaml` parser's `maxDepth` option, whose parser default is also 100. When a YAML document exceeds it, the parser reports `nesting exceeded maxDepth (<N>)`. YAML and JSON apply their own nesting rules, so the same numeric limit need not accept identical structures.

Collection checks run after parsing and before configuration validation, defaults processing, or cloning. They include free-form configuration. File size and collection limits accept values equal to the limit. Failures identify the input and the exceeded limit, with error codes `ERR_CONFIG_TOO_LARGE`, `ERR_CONFIG_TOO_DEEP`, or `ERR_CONFIG_COLLECTION_TOO_LARGE`.

The JSON depth limit also applies to `package.json` and bundled schema files read through the same parser. Very small values can therefore reject a bundled schema before project loading completes. These files have no byte limit from this mechanism. Configuration objects supplied directly through the API are outside these file-read checks.

These limits apply to individual inputs. They do not bound a project graph assembled from many dependency documents, or the total work of a build. CI jobs and containers that build untrusted projects must also set process or container limits for memory, CPU, and file count.

## Example

```yaml
Expand Down
32 changes: 27 additions & 5 deletions packages/project/lib/graph/Module.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import fs from "graceful-fs";
import path from "node:path";
import {promisify} from "node:util";
const readFile = promisify(fs.readFile);
const stat = promisify(fs.stat);
import {loadAll as jsyamlLoadAll} from "js-yaml";
import ui5YamlSchema from "./helpers/ui5YamlSchema.js";
import {createReader} from "@ui5/fs/resourceFactory";
import Specification from "../specifications/Specification.js";
import {validate} from "../validation/validator.js";
import parseJSON from "../utils/json.js";
import {
assertMaxBytes, assertCollectionSize, getConfigLimits, getManifestMaxBytes, readResourceString
} from "../utils/limits.js";

import {getLogger} from "@ui5/logger";

Expand Down Expand Up @@ -286,16 +290,20 @@ class Module {

async _readConfigFile() {
const configPath = this._configPath;
const limits = getConfigLimits();
let configFile;
if (path.isAbsolute(configPath)) {
// Handle absolute file paths with the native FS module
try {
assertMaxBytes((await stat(configPath)).size, {input: configPath, limit: limits.bytes});
configFile = await readFile(configPath, {encoding: "utf8"});
} catch (err) {
// TODO: Caller might wants to ignore exceptions for ENOENT errors for non-root projects
// However, this decision should not be made here
throw new Error("Failed to read configuration for module " +
const error = new Error("Failed to read configuration for module " +
`${this.getId()} at '${configPath}'. Error: ${err.message}`);
error.code = err.code;
throw error;
}
} else {
// Handle relative file paths with the @ui5/fs (virtual) file system
Expand All @@ -314,7 +322,7 @@ class Module {
}
return null;
}
configFile = await configResource.getString();
configFile = await readResourceString(configResource, {input: configPath, limit: limits.bytes});
}

let configs;
Expand All @@ -324,12 +332,18 @@ class Module {
// behaviour.
configs = jsyamlLoadAll(configFile, undefined, {
filename: configPath,
schema: ui5YamlSchema
schema: ui5YamlSchema,
maxDepth: limits.depth
});
} catch (err) {
if (err.name === "YAMLException") {
throw new Error("Failed to parse configuration for project " +
const error = new Error("Failed to parse configuration for project " +
`${this.getId()} at '${configPath}'\nError: ${err.message}`);
// js-yaml reports the configured maxDepth breach as "nesting exceeded maxDepth (<N>)".
if (err.reason?.startsWith("nesting exceeded maxDepth")) {
error.code = "ERR_CONFIG_TOO_DEEP";
}
throw error;
} else {
throw err;
}
Expand All @@ -340,6 +354,10 @@ class Module {
return configs;
}

for (const config of configs) {
assertCollectionSize(config, {input: configPath, limit: limits.collection});
}

// Validate found configurations with schema
// Validation is done again in the Specification class. But here we can reference the YAML file
// which adds helpful information like the line number
Expand Down Expand Up @@ -396,7 +414,11 @@ class Module {
const reader = this.getReader();
const buildManifestResource = await reader.byPath("/.ui5/build-manifest.json");
if (buildManifestResource) {
return parseJSON(await buildManifestResource.getString(), buildManifestResource.getPath());
const input = buildManifestResource.getPath();
const content = await readResourceString(buildManifestResource, {input, limit: getManifestMaxBytes()});
const manifest = parseJSON(content, input);
assertCollectionSize(manifest?.project, {input, limit: getConfigLimits().collection});
return manifest;
}
}

Expand Down
9 changes: 7 additions & 2 deletions packages/project/lib/specifications/types/Application.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fsPath from "node:path";
import ComponentProject from "../ComponentProject.js";
import {createReader} from "@ui5/fs/resourceFactory";
import parseJSON from "../../utils/json.js";
import {getManifestMaxBytes, readResourceString} from "../../utils/limits.js";

/**
* Application
Expand Down Expand Up @@ -245,14 +246,18 @@ class Application extends ComponentProject {
error.code = "ENOENT"; // "File or directory does not exist"
throw error;
}
return parseJSON(await resource.getString(), `${this._webappPath}${filePath}`);
const input = `${this._webappPath}${filePath}`;
const content = await readResourceString(resource, {input, limit: getManifestMaxBytes()});
return parseJSON(content, input);
}).catch((err) => {
if (err.code === "ENOENT") {
throw err;
}
throw new Error(
const error = new Error(
`Failed to read ${filePath} for project ` +
`${this.getName()}: ${err.message}`);
error.code = err.code;
throw error;
});
}
}
Expand Down
9 changes: 7 additions & 2 deletions packages/project/lib/specifications/types/Component.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import posixPath from "node:path/posix";
import ComponentProject from "../ComponentProject.js";
import {createReader} from "@ui5/fs/resourceFactory";
import parseJSON from "../../utils/json.js";
import {getManifestMaxBytes, readResourceString} from "../../utils/limits.js";

/**
* Component
Expand Down Expand Up @@ -285,14 +286,18 @@ class Component extends ComponentProject {
error.code = "ENOENT"; // "File or directory does not exist"
throw error;
}
return parseJSON(await resource.getString(), `${this._srcPath}${filePath}`);
const input = `${this._srcPath}${filePath}`;
const content = await readResourceString(resource, {input, limit: getManifestMaxBytes()});
return parseJSON(content, input);
}).catch((err) => {
if (err.code === "ENOENT") {
throw err;
}
throw new Error(
const error = new Error(
`Failed to read ${filePath} for project ` +
`${this.getName()}: ${err.message}`);
error.code = err.code;
throw error;
});
}

Expand Down
12 changes: 10 additions & 2 deletions packages/project/lib/specifications/types/Library.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {promisify} from "node:util";
import ComponentProject from "../ComponentProject.js";
import * as resourceFactory from "@ui5/fs/resourceFactory";
import parseJSON from "../../utils/json.js";
import {getManifestMaxBytes, readResourceString} from "../../utils/limits.js";

/**
* Library
Expand Down Expand Up @@ -392,6 +393,9 @@ class Library extends ComponentProject {
`at ${filePath}`);
}
} catch (err) {
if (err.code?.startsWith("ERR_CONFIG_")) {
throw err;
}
// For Specification Version 5.0+, non-framework libraries must provide a manifest.json
if (this.getSpecVersion().gte("5.0") && !this.isFrameworkProject()) {
throw new Error(
Expand Down Expand Up @@ -510,15 +514,19 @@ class Library extends ComponentProject {
const resource = manifestResources[0];
try {
return {
content: parseJSON(await resource.getString(), resource.getPath()),
content: parseJSON(await readResourceString(resource, {
input: resource.getPath(), limit: getManifestMaxBytes()
}), resource.getPath()),
filePath: resource.getPath()
};
} catch (err) {
if (err.code === "ENOENT") {
throw err;
}
throw new Error(
const error = new Error(
`Failed to read ${resource.getPath()} for project ${this.getName()}: ${err.message}`);
error.code = err.code;
throw error;
}
});
}
Expand Down
4 changes: 4 additions & 0 deletions packages/project/lib/utils/json.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {assertJsonDepth, getJsonMaxDepth} from "./limits.js";

/**
* Parses JSON content, attaching the file path to any parse failure so the
* root cause (empty file vs. syntax error) is always identifiable.
Expand All @@ -11,8 +13,10 @@
* @returns {object} Parsed JSON
* @throws {SyntaxError} with code "ERR_EMPTY_JSON" if content is empty/whitespace-only
* @throws {SyntaxError} with code "ERR_INVALID_JSON" if content is malformed JSON
* @throws {Error} with code "ERR_CONFIG_TOO_DEEP" if content exceeds UI5_JSON_MAX_DEPTH
*/
export default function parseJSON(content, filePath) {
assertJsonDepth(content, {input: filePath, limit: getJsonMaxDepth()});
try {
return JSON.parse(content);
} catch (err) {
Expand Down
149 changes: 149 additions & 0 deletions packages/project/lib/utils/limits.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Read a limit when it is used so environment overrides apply to subsequent reads.
*
* @param {string} name Environment variable
* @param {number} fallback Default limit
* @returns {number} Positive safe integer
*/
function getLimit(name, fallback) {
const value = process.env[name];
if (value === undefined) {
return fallback;
}
const limit = Number(value);
if (!/^\d+$/.test(value) || !Number.isSafeInteger(limit) || limit <= 0) {
const error = new Error(`Invalid ${name}: expected a positive safe integer, received '${value}'`);
error.code = "ERR_CONFIG_INVALID_LIMIT";
throw error;
}
return limit;
}

/**
* @returns {object} YAML byte, nesting and collection limits
*/
export function getConfigLimits() {
return {
bytes: getLimit("UI5_CONFIG_MAX_BYTES", 1024 * 1024),
depth: getLimit("UI5_CONFIG_MAX_DEPTH", 100),
collection: getLimit("UI5_CONFIG_MAX_COLLECTION", 10000)
};
}

/**
* @returns {number} Manifest byte limit
*/
export function getManifestMaxBytes() {
return getLimit("UI5_MANIFEST_MAX_BYTES", 4 * 1024 * 1024);
}

/**
* @returns {number} JSON nesting limit
*/
export function getJsonMaxDepth() {
return getLimit("UI5_JSON_MAX_DEPTH", 100);
}

/**
* @param {number} byteSize Input size in bytes
* @param {object} options Check options
* @param {string} options.input Input path
* @param {number} options.limit Maximum size in bytes
* @throws {Error} If the input exceeds the byte limit
*/
export function assertMaxBytes(byteSize, {input, limit}) {
if (byteSize > limit) {
const error = new Error(
`Configuration file '${input}' exceeds the maximum size of ${limit} bytes (${byteSize} bytes)`);
error.code = "ERR_CONFIG_TOO_LARGE";
throw error;
}
}

/**
* Reject over-nested JSON before JSON.parse allocates the parsed structure.
* JSON.parse enforces no nesting limit of its own.
*
* @param {string} str JSON content
* @param {object} options Check options
* @param {string} options.input Input path
* @param {number} options.limit Maximum number of nested objects and arrays
* @throws {Error} If the input exceeds the nesting limit
*/
export function assertJsonDepth(str, {input, limit}) {
if (typeof str !== "string") {
return;
}
let depth = 0;
let inString = false;
for (let i = 0; i < str.length; i++) {
const char = str[i];
if (inString) {
if (char === "\\") {
i++; // Skip the escaped character, including escaped quotes and backslashes.
} else if (char === "\"") {
inString = false;
}
} else if (char === "\"") {
inString = true;
} else if (char === "{" || char === "[") {
if (++depth > limit) {
const error = new Error(
`Configuration file '${input}' exceeds the maximum JSON nesting depth of ${limit}`);
error.code = "ERR_CONFIG_TOO_DEEP";
throw error;
}
} else if (char === "}" || char === "]") {
depth--;
}
}
}

/**
* Check each collection once, including shared or cyclic YAML aliases.
*
* @param {*} value Parsed configuration
* @param {object} options Check options
* @param {string} options.input Input path
* @param {number} options.limit Maximum array length or object property count
* @throws {Error} If a collection exceeds the limit
*/
export function assertCollectionSize(value, {input, limit}) {
const pending = [value];
const visited = new WeakSet();
while (pending.length) {
const current = pending.pop();
if (!current || typeof current !== "object" || visited.has(current)) {
continue;
}
visited.add(current);
const isArray = Array.isArray(current);
const keys = isArray ? undefined : Object.keys(current);
const size = isArray ? current.length : keys.length;
if (size > limit) {
const error = new Error(
`Configuration file '${input}' exceeds the maximum collection size of ${limit} ` +
`(${size} ${isArray ? "array entries" : "object properties"})`);
error.code = "ERR_CONFIG_COLLECTION_TOO_LARGE";
throw error;
}
for (let i = 0; i < size; i++) {
pending.push(current[isArray ? i : keys[i]]);
}
}
}

/**
* Check the resource size before requesting its content. FileSystem resources
* supply the size from stat metadata.
*
* @param {@ui5/fs/Resource} resource Input resource
* @param {object} options Check options
* @param {string} options.input Input path
* @param {number} options.limit Maximum size in bytes
* @returns {Promise<string>} Resource content
*/
export async function readResourceString(resource, {input, limit}) {
assertMaxBytes(await resource.getSize(), {input, limit});
return resource.getString();
}
Loading
Loading