NEWS
Security Audit of Backstage
X41 performed an audit of the Backstage open platform for building developer portals, sponsored by the great folks at OSTIF and supported by Spotify Engineering. The issues have been addressed by the Backstage team accordingly and the updated code is available in the repository.
The full report of the security audit can be downloaded under the following links:
The issues found during the source code audit were assigned the following CVEs:
- CVE-2024-45815: A malicious actor with authenticated access to a Backstage instance with the catalog backend plugin installed is able to interrupt the service using a specially crafted query to the catalog API.
- CVE-2024-45816: When using the AWS S3 or GCS storage provider for TechDocs, it is possible to access content in the entire storage bucket.
- CVE-2024-46976: An attacker with control of the contents of the TechDocs storage buckets is able to inject executable scripts in the TechDocs content that will be executed in the victim’s browser when browsing documentation or navigating to an attacker provided link.
Backstage
Backstage is an open-source framework and software catalog that allows developers to quickly ship high-quality code. It is written in TypeScript and extensible via plugins in many ways. It supports various authentication schemes, implements a permission level to separate resources across different users and allows users to update their documentation in various data formats. As a framework to develop complex applications, vulnerabilities in the platform itself and its core plugins could have a wider scale technical impact on many other systems. They could allow an attacker to gain sensitive information about an organization’s digital assets, such as the source code or deployment configurations of software components.
Audit Results and Notable Findings
Our security source code audit identified three high and one medium security issues. Additionally, seven informational findings were identified. The source code of Backstage was inspected for vulnerabilities by security experts Ali Basma, Eric Sesterhenn, JM, Markus Vervier and Yassine El Baaj using manual code review and code analysis tools.
The most severe issue discovered allows an attacker to take over arbitrary accounts in Backstage if multiple authentication providers are enabled. Another high-severity issue allows attackers to perform prototype pollution and achieve a Denial of Service attack or, depending on the circumstances, potentially achieve SQL injection or arbitrary code execution. Additionally, two issues found in the TechDocs plugin respectively allow an attacker to fetch files outside of the configured root directory from a cloud provider and bypass a Cross-Site Scripting filter via unfiltered content types.
Possible Account Hijacking Via OAuth When Using Built-in Backstage Resolvers
Backstage can be configured to have any number of OAuth external authentication providers such as GitHub or Google. Once a user enters the correct credentials for the external authentication provider, Backstage will check if that user can be mapped to a Backstage user identity, which typically lives in the Backstage Catalog. The condition that decides whether a user authenticated through an external authentication provider indeed exists in the Backstage Catalog is determined by a Backstage Resolver.
A possible account hijacking scenario when using the built-in resolvers emailLocalPartMatchingUserEntityName
and usernameMatchingUserEntityName
was identified. If the victim does not already have an account on the target authentication provider, the use of one of these resolvers could allow an attacker to perform one of the following attacks:
- If the external authentication provider is configured to use the
emailLocalPartMatchingUserEntityName
, the attacker can create an account with a specially crafted email address that matches the username of the victim and hijack their account. - If the external authentication provider is configured to use the
usernameMatchingUserEntityName
, the attacker can create an account with the same username as of the victims and hijack their account.
This means that a victim that is not registered on GitLab, using GitHub as an authentication provider and registered there with the username johndoe
can see their account hijacked by an attacker if the latter creates a new GitLab account with the username johndoe
or an email address with a local part matching that same username, namely johndoe@attacker-controlled.com
.
This issue was addressed in pull request 26969. The resolvers emailLocalPartMatchingUserEntityName
and usernameMatchingUserEntityName
were explicitly documented as being subject to attackers’ abuse. Occurrences of examples listing multiple sign-in resolvers were removed from the documentation. A priority order was set on the different resolvers for each authentication provider, always putting emailLocalPartMatchingUserEntityName
and usernameMatchingUserEntityName
at the last position. Additionally, the usage of the new allowedDomains option for the emailLocalPartMarchingUserEntityName resolver was recommended. This ensures that only users owning an email address belonging to a specific domain are allowed to log into Backstage. Finally, the threat model2 was also updated to strongly recommend the use of a single sign-in resolver per Backstage instance and also include several warnings regarding misconfigurations that can occur when setting up Backstage.
CVE-2024-45815: Server-Side Prototype Pollution via Filters Request Parameter
The server-side code responsible for parsing and processing the filter parameter is vulnerable to prototype pollution. This can lead to a denial-of-service condition or even the injection and execution of unintended database queries. Depending on the context and conditions, prototype pollution can even lead to arbitrary code execution.
The key value __proto__
can be inject in the filter
parameter, as seen in the following request:
GET /api/catalog/entities/by-query?limit=0&filter=foo=guest,__proto__=`"' HTTP/1.1
Host: localhost:7007
authorization: Bearer eyJ0eXAiOiJ2bmQuYmFja3N0YW...
Content-Type: application/json
Accept: */*
Origin: http://localhost:3000
Sec-Fetch-Site: same-site
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://localhost:3000/
Connection: keep-alive
This will result in an HTTP error 500 being returned by the server and cause errors for all subsequent calls to Database.prepare()
as shown in following stacktrace:
[1] 2024-08-28T19:55:16.506Z rootHttpRouter error Request failed with status 500 select * from
`user_info` where `user_entity_ref` = 'user:development/guest' and `values` = '`"''' limit 1
- no such column: values type=errorHandler code=SQLITE_ERROR stack=SqliteError: select * from
`user_info` where `user_entity_ref` = 'user:development/guest' and `values` = '`"''' limit 1
- no such column: values
[1] at Database.prepare (/data/backstage/node_modules/better-sqlite3/lib/methods/wrappers.js:5:21)
[SNIP]
[1] at UserInfoDatabaseHandler.getUserInfo (/data/backstage/node_modules/@backstage/plugin-auth-backend/src/identity/UserInfoDatabaseHandler.ts:51:18)
[1] at <anonymous> (/data/backstage/node_modules/@backstage/plugin-auth-backend/src/identity/router.ts:102:22)
[1] 2024-08-28T19:55:17.630Z catalog warn Failed to load processing items update `refresh_state`
set `next_update_at` = datetime('now', '138.6517332388715 seconds'), `values` = '`"''' where
1 = 0 - no such column: values code=SQLITE_ERROR stack=SqliteError: update `refresh_state`
set `next_update_at` = datetime('now', '138.6517332388715 seconds'), `values` = '`"''' where
1 = 0 - no such column: values
[1] at Database.prepare (/data/backstage/node_modules/better-sqlite3/lib/methods/wrappers.js:5:21)
[SNIP]
[1] at DefaultProcessingDatabase.getProcessableEntities (/data/backstage/node_modules/@backstage/plugin-catalog-backend/src/database/DefaultProcessingDatabase.ts:234:11)
[1] at options.database.transaction.doNotRejectOnRollback (/data/backstage/node_modules/@backstage/plugin-catalog-backend/src/database/DefaultProcessingDatabase.ts:289:20)
As seen in the log, the value `”‘ became part of a query to the SQL database in escaped form, but caused an error due to the query being incorrectly formed. The reason for this lies in the previous request and (seemingly) unrelated code found in file packages/core-components/src/components/Table/Table.tsx
and shown in the following code snippet:
function extractValueByField(data: any, field: string): any | undefined {
const path = field.split('.');
let value = data[path[0]];
for (let i = 1; i < path.length; ++i) {
if (value === undefined) {
return value;
}
const f = path[i]; // MARK1 const f is extract from the untrustworthy path input and assumed to be '__proto__'
value = value[f]; // MARK2 value[f] is setting value to it`s own prototype property like if it was value['__proto__']
}
return value; // prototype of 'value' is returned instead of the object 'value'
}
Since the function returns the prototype of the object, modifications of its properties will translate to all other objects that are later derived from its prototype. This seems to be the case and the reason that the value for key __proto__
in the filters is appearing again in subsequent database requests, breaking the functionality of backstage until the process is restarted. Setting a key to the value constructor also causes a pollution situation because a function type is overwritten by a string type.
During the test, no way was identified to achieve direct code execution since the prototype was overwritten by a string type value and not by another object or array value. This prevents classical exploitation or access to prototype pollution gadgets. Also the SQL query could not be subverted maliciously since the polluted value was still correctly escaped by the prepared statement handling code. However, it cannot be ruled out that with more time available, an accessible gadget could be found, or that a database operation that does not escape the value properly could be triggered.
This issue has been fixed in commit 01c673b5. A Map object is now used to store the filters by key. In comparison to a Record object, a Map cannot be polluted, which mitigates the issue.
CVE-2024-46976: Circumvention of XSS Protection
The function getHeadersForFileExtension()
is used to set the Content-Type of files served via TechDocs. It calls the helper function getContentTypeForExtension()
to resolve the MIME type based on the file extension. To prevent XSS when rendering TechDocs documents, a filter is applied to not serve HTML, XML and SVG files, as shown in the following excerpt of the plugins/techdocs-node/src/stages/publish/helpers.ts
script:
const getContentTypeForExtension = (ext: string): string => {
const defaultContentType = 'text/plain; charset=utf-8';
// Prevent sanitization bypass by preventing browsers from directly rendering
// the contents of untrusted files.
if (ext.match(/htm|xml|svg/i)) {
return defaultContentType;
}
return mime.contentType(ext) || defaultContentType;
};
This filter is applied by various TechDocs file storage providers such as awsS3 and GoogleStorage, since the storage might be controlled by external parties.
Several different MIME types exist that allow the execution of JavaScript. By cross-referencing these with the mime-db used by Backstage, the following unfiltered file extensions could be identified:
- .appcache (text/cache-manifest)
- .manifest (text/cache-manifest)
- .mathml (application/mathml+xml)
- .owl (application/octet-stream)
- .rdf (application/rdf+xml)
- .rng (application/xml)
- .vtt (text/vtt)
- .xht (application/xhtml+xml)
- .xsd (application/xml)
- .xsl (application/xml)
Depending on the browser and final CSP set, these can allow the execution of JavaScript in the victim’s browser.
The following code shows the content of an XHT file that loads the script hi.js
:
<a:script xmlns:a="http://www.w3.org/1999/xhtml" src="hi.js" type="application/javascript"/>
This issue has been mitigated in commit e94df98 by verifying that the file extension and its corresponding MIME type are not in the list of non-allowed content types. However, this does not take into consideration that browsers might support new MIME types that enable JavaScript execution in the future.
CVE-2024-45816: Directory Traversal in TechDocs
When using the awsS3 storage provider for TechDocs with a bucketRootPath
, it is possible to access files outside the configured bucketRootPath
. The function docsRouter()
effectively passes the user-controlled req.path
to path.posix.join()
, which joins the paths and then normalizes them, as shown in the following excerpt of the plugins/techdocs-node/src/stages/publish/helpers.ts
script:
docsRouter(): express.Handler {
return async (req, res) => {
const decodedUri = decodeURI(req.path.replace(/^\//, ''));
// filePath example - /default/component/documented-component/index.html
const filePathNoRoot = this.legacyPathCasing ? decodedUri : lowerCaseEntityTripletInStoragePath(decodedUri);
// Prepend the root path to the relative file path
const filePath = path.posix.join(this.bucketRootPath, filePathNoRoot);
// [SNIP]
const resp = await this.storageClient.send(
new GetObjectCommand({ Bucket: this.bucketName, Key: filePath }),
);
// [SNIP]
res.send(await streamToBuffer(resp.Body as Readable));
} // [SNIP]
};
By sending a specially crafted HTTP path, it is possible to perform directory traversal and access objects outside the bucketRootPath
. Although bucketRootPath was configured to point to the /static/
directory, X41 managed to access files in the /secret/
directory using the command shown in listing 4.8.
curl -H "Cookie: $BACKSTAGE_AUTH" --path-as-is 'http://localhost:7007/api/techdocs/static/docs/default/component/backstage/index.html/../../../../../secret/secret.txt'
This returned the contents of the /secret/secret.txt
file within the bucket. GoogleStorage handles requests in a very similar way and is likely also affected. OpenStackSwift and AzureBlobStorage do not support a bucketRootPath
. The Local TechDocs provider uses express.static()
and X41 believes that it is not affected.
This issue was addressed in commit d995579 by verifying that the resolved path is not located outside the configured base for serving TechDocs. Since the GoogleStorage was also affected, the necessary changes have also been applied there.
Conclusion
Overall, the Backstage framework appears to be on a good security level compared to source code of similar size and complexity. It is visible that it was designed with security in mind. Nevertheless, due to the complexity and high pace of the development, the potential for vulnerabilities being introduced is always present. It is recommended to perform code audits regularly to detect issues early.
If you are interested in working with us on such projects in the future, remote or in-office, ping us!.