Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
a5e1a4a
Bump CLI dependencies (#5383)
myovchev Apr 16, 2026
9f458b5
Fix choices IDs (#5379)
myovchev Apr 16, 2026
b9b32bd
Native browser shortcuts work again (#5384)
myovchev Apr 17, 2026
08845c5
Pro 8838 charset (#5385)
BoDonkey Apr 22, 2026
d45e27f
Log aposResponse errors (#5386)
haroun Apr 22, 2026
f3501f4
merge back the thanks (#5388)
boutell Apr 23, 2026
0d85771
Postgres (#5365)
boutell Apr 23, 2026
dfd25cf
forgot to include a changeset (#5390)
boutell Apr 24, 2026
b360b05
ignore inline table array as draggable ui for windows (#5392)
stuartromanek Apr 29, 2026
77a2968
Layout focus orchestration (#5393)
myovchev May 4, 2026
82ff110
Pro 9405 remove hreflang (#5395)
BoDonkey May 4, 2026
d65c5d6
Pro 9406 base url (#5396)
BoDonkey May 4, 2026
008417f
Bump dependencies (#5398)
myovchev May 5, 2026
7ab9961
Pro 9405 remove hreflang (#5395)
BoDonkey May 6, 2026
e9b3bac
PRO-9467: remove defunct and nonpublic oembed providers and improve d…
boutell May 6, 2026
bc8f7be
Layout editable gap (#5397)
myovchev May 7, 2026
2e2f3b4
a11y fixes (#5401)
myovchev May 7, 2026
13f2c69
clarifications (#5403)
boutell May 8, 2026
08dfcac
Merge commit from fork
boutell May 13, 2026
8d4c882
Merge commit from fork
boutell May 13, 2026
5d1a028
Merge commit from fork
boutell May 13, 2026
41670a3
Merge commit from fork
boutell May 13, 2026
01bacd2
Merge commit from fork
boutell May 13, 2026
8098017
Merge commit from fork
boutell May 13, 2026
ffa58b3
mergeback (#5409)
boutell May 13, 2026
e52e1df
mergeback (#5414)
boutell May 13, 2026
cf1a639
Fix focus trap on the last element in a modal (#5406)
myovchev May 19, 2026
167c2b5
Fix import-export noise (#5399)
myovchev May 19, 2026
2191c8a
A11y fixes part 3 (#5416)
myovchev May 19, 2026
b1c7c9b
Fix initial focus trap issue, introduced with recent changes (#5426)
myovchev May 20, 2026
b7f9ad5
PRO-9542: fix the bug that breaks sitemaps for RA (#5433)
boutell May 26, 2026
cef4f89
Feature/prevent infinite redirects (#5429)
haroun May 28, 2026
33bb4c0
add draggable: false support to non-inline array field (#5435)
BoDonkey Jun 2, 2026
ddcdaa7
Make logged-in cookie name configurable via options (#5430)
RohithVangalla1 Jun 2, 2026
f98aed7
Revert "Make logged-in cookie name configurable via options (#5430)" …
boutell Jun 2, 2026
950927d
Feature create-apostrophe (#5425)
myovchev Jun 2, 2026
f67c272
Fix new schema areas in existing documents (Astro) (#5434)
myovchev Jun 2, 2026
c943313
PRO-6295: jsx as an optional alternative to nunjucks (#5391)
boutell Jun 2, 2026
1fa59e4
no watch in prod (#5439)
boutell Jun 3, 2026
34dca7a
Fix new schema areas in existing documents (Astro) Part II (#5440)
myovchev Jun 3, 2026
4be7e00
Fix raw-text sanitization bypass vulnerability and add regression tes…
Dipanshusinghh Jun 4, 2026
862d760
changeset for singh contribution (#5442)
boutell Jun 4, 2026
7ce6296
Fix relationship select scrolling issue (#5445)
myovchev Jun 5, 2026
97e9d56
jsx changeset (#5446)
boutell Jun 5, 2026
e38ffe8
Ensure install of the project root for astro projects (#5449)
myovchev Jun 8, 2026
a70541a
test node 26 (#5450)
boutell Jun 8, 2026
8d084b6
Add link for telemetry policy (#5455)
BoDonkey Jun 9, 2026
afbceea
remove absent options (#5456)
boutell Jun 9, 2026
c824514
Remove consumed 4.30.0 changesets from main (#5454)
BoDonkey Jun 10, 2026
874582f
cli links that are correct, or will be post publish (#5458)
boutell Jun 10, 2026
203ff26
release db connect to solve chicken and egg problem in cypress-tools …
boutell Jun 10, 2026
6a11a08
Corrects documentation links (#5457)
BoDonkey Jun 10, 2026
ef817dd
Merge commit from fork
boutell Jun 10, 2026
b3981ec
Merge commit from fork
boutell Jun 10, 2026
c91b6c7
Merge commit from fork
boutell Jun 10, 2026
75f680c
Merge commit from fork
boutell Jun 10, 2026
134b7e3
Merge branch 'main' into latest-security-q2
boutell Jun 10, 2026
cf46fc8
Merge branch 'main' into latest-security-q2
boutell Jun 10, 2026
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
5 changes: 5 additions & 0 deletions .changeset/boozy-manual-severaltoms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sanitize-html": patch
---

Security: added a number of new attributes to be protected against unsafe URLs, e.g. `javascript:` and similar. None of these are used in the default configuration of `sanitize-html` or `apostrophe` or likely to be used there, and some attributes, like an `action` for a `form`, are inherently unsafe to allow if XSS protection is your goal. Nevertheless it makes sense to block certain URL types where they are not appropriate. Some attributes are not supported at all by modern browsers but are included for completeness. Thanks to [crattack](https://github.com/crattack) for reporting the vulnerability.
5 changes: 5 additions & 0 deletions .changeset/file-pretty-url-host-header-ssrf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"apostrophe": patch
---

Security: when `@apostrophecms/file` pretty URLs are enabled (`prettyUrls: true`), the upstream request used to serve the file is no longer built from the incoming `Host` header. The self-request is now resolved against the site's configured `baseUrl` (via `req.baseUrl`), falling back to the request host only when no `baseUrl` is configured. This closes a server-side request forgery (SSRF) vector in which the `Host` header could steer the proxied fetch at another host. The real-world risk was low: the path is constrained to an existing attachment's `/uploads/attachments/<cuid>-<slug>.<ext>`, and cuids are unique and immutable, so any reachable content was already public via the front door. Thanks to [EchoSkorJjj](https://github.com/EchoSkorJjj) for reporting the issue.
5 changes: 5 additions & 0 deletions .changeset/proud-moons-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"apostrophe": patch
---

Security fix: server-side prototype pollution (CWE-1321) via dot-notation paths. `apos.util.set()` and `apos.util.get()` now refuse to traverse `__proto__`, `constructor` and `prototype` path segments. Previously an authenticated editor could send a PATCH REST API request whose patch operators (for example `$pullAll` with a key of `__proto__.publicApiProjection`) wrote to `Object.prototype`. A polluted `publicApiProjection` defeated the `publicApiCheck()` authorization gate on piece-type REST endpoints for subsequent unauthenticated requests, for the lifetime of the Node.js process. All users should update. Thanks to [tonghuaroot](https://github.com/tonghuaroot), [H3xV0rT3x](https://github.com/H3xV0rT3x), and [5h1kh4r](https://github.com/5h1kh4r) for reporting the vulnerability.
5 changes: 5 additions & 0 deletions .changeset/seo-analytics-xss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apostrophecms/seo": patch
---

Security: the Google Analytics tracking ID (`seoGoogleTrackingId`) and Google Tag Manager ID (`seoGoogleTagManager`) global SEO fields were interpolated directly into the bodies of inline `<script>` tags without escaping. Any user permitted to edit the global document, including editors and contributors (if their submission were approved), could set these fields to a value that broke out of the surrounding script and executed arbitrary JavaScript for every visitor on every page (stored XSS). These values are now emitted as escaped `json` nodes, matching the JSON-LD handling, so they can no longer terminate the `<script>` element or escape the string literal they sit in. All projects using `@apostrophecms/seo` with untrusted editors should upgrade promptly to close this vulnerability. Thanks to [H3xV0rT3x](https://github.com/H3xV0rT3x) and [hibrian827](https://github.com/hibrian827) for reporting the issue.
17 changes: 9 additions & 8 deletions packages/apostrophe/modules/@apostrophecms/file/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,14 +248,15 @@ module.exports = {
const uglyUrl = self.apos.attachment.url(file.attachment, {
prettyUrl: false
});
// For relative URLs (local uploadfs, not CDN), resolve
// against the current server's origin so the proxy can
// make the self-request. During static builds
// `attachment.url()` may return only a path.
const proxyUrl = uglyUrl.startsWith('/')
? `${req.protocol}://${req.get('host')}${uglyUrl}`
: uglyUrl;
return await streamProxy(req, proxyUrl, { error: self.apos.util.error });
// `uglyUrl` may be relative (local uploadfs) or absolute
// (S3/CDN). For the relative case `streamProxy` resolves it
// against `req.baseUrl`, which reflects the configured
// `baseUrl` (or locale hostname) and only falls back to the
// request host when the site has none. We deliberately do
// not build the upstream URL from the raw `Host` header
// here, as that is attacker-controlled and would allow the
// self-request to be redirected to an arbitrary host (SSRF).
return await streamProxy(req, uglyUrl, { error: self.apos.util.error });
} catch (e) {
self.apos.util.error('Error in pretty URL route:', e);
return res.status(500).send('error');
Expand Down
17 changes: 17 additions & 0 deletions packages/apostrophe/modules/@apostrophecms/util/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ const util = require('util');
const { stripIndent } = require('common-tags');
const glob = require('../../../lib/glob.js');

// Dot-path segments that must never be traversed when walking a
// user-supplied path in `apos.util.get` and `apos.util.set`. Following any
// of these reaches the prototype chain and enables server-side prototype
// pollution (CWE-1321), e.g. a PATCH `$pullAll` key of
// `__proto__.publicApiProjection`.
const unsafePathSegments = new Set([ '__proto__', 'constructor', 'prototype' ]);

module.exports = {
options: {
alias: 'util',
Expand Down Expand Up @@ -755,6 +762,10 @@ module.exports = {
if (o == null) {
return undefined;
}
if (unsafePathSegments.has(p)) {
// Never read through the prototype chain (CWE-1321)
return undefined;
}
o = o[p];
}
}
Expand Down Expand Up @@ -819,6 +830,12 @@ module.exports = {
}
}
path = path.split('.');
for (p of path) {
if (unsafePathSegments.has(p)) {
// Refuse to write through the prototype chain (CWE-1321)
throw self.apos.error('invalid', `Unsafe property name "${p}" in dot path`);
}
}
for (i = 0; (i < (path.length - 1)); i++) {
p = path[i];
o = o[p];
Expand Down
28 changes: 28 additions & 0 deletions packages/apostrophe/test/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,34 @@ describe('Files', function() {
}
});

it('should ignore a spoofed Host header when proxying the pretty URL (SSRF regression)', async function() {
const req = apos.task.getAnonReq();
try {
apos.file.options.prettyUrls = true;
const files = await apos.file.find(req).toArray();
assert.strictEqual(files.length, 1);
const file = files[0];
const attachment = apos.attachment.first(file);
const url = apos.attachment.url(attachment);
assert(url);
// Send an attacker-controlled Host header (e.g. the cloud metadata
// address from the advisory). The upstream fetch must be resolved
// against the server-trusted baseUrl, not this header, so the
// legitimate content is still served and the request is never
// steered at the spoofed host.
const response = await apos.http.get(url, {
headers: {
Host: '169.254.169.254'
},
fullResponse: true
});
assert.strictEqual(response.status, 200);
assert.strictEqual(response.body, attachment.data);
} finally {
apos.file.options.prettyUrls = false;
}
});

});

describe('Files with i18n locale prefixes', function() {
Expand Down
103 changes: 103 additions & 0 deletions packages/apostrophe/test/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,109 @@ describe('Utils', async function() {
assert(data.shoes[0].size === 8);
});

// Server-Side Prototype Pollution (CWE-1321) regression coverage.
// apos.util.set and apos.util.get traverse user-supplied dot-notation
// paths. A segment of `__proto__`, `constructor` or `prototype` must
// never be followed, or an authenticated editor could write to
// Object.prototype (for example via the $pullAll patch operator) and
// poison authorization checks process-wide. See GHSA-6h5j-32cf-4253.

it('utils.set must reject a __proto__ segment instead of polluting Object.prototype', function() {
const data = {};
try {
assert.throws(() => {
apos.util.set(data, '__proto__.polluted', 'yes');
}, { name: 'invalid' });
assert.strictEqual({}.polluted, undefined);
assert.strictEqual(data.polluted, undefined);
} finally {
// Belt and suspenders: if the guard ever regresses, do not leak a
// polluted prototype into the rest of the suite.
delete Object.prototype.polluted;
}
});

it('utils.set must reject a constructor.prototype segment', function() {
const data = {};
try {
assert.throws(() => {
apos.util.set(data, 'constructor.prototype.polluted', 'yes');
}, { name: 'invalid' });
assert.strictEqual({}.polluted, undefined);
} finally {
delete Object.prototype.polluted;
}
});

it('utils.set must reject a trailing __proto__ segment rather than replacing the prototype', function() {
const data = {};
assert.throws(() => {
apos.util.set(data, '__proto__', { polluted: 'yes' });
}, { name: 'invalid' });
assert.strictEqual(Object.getPrototypeOf(data), Object.prototype);
assert.strictEqual(data.polluted, undefined);
});

it('utils.set must reject a dangerous segment exposed after an @ reference', function() {
const data = {
items: [
{
_id: 'abc',
sub: {}
}
]
};
try {
assert.throws(() => {
apos.util.set(data, '@abc.__proto__.polluted', 'yes');
}, { name: 'invalid' });
assert.strictEqual({}.polluted, undefined);
} finally {
delete Object.prototype.polluted;
}
});

it('utils.get must not traverse into the prototype chain via __proto__', function() {
// Without the guard this returns Object.prototype.toString (a function).
assert.strictEqual(apos.util.get({ a: 1 }, '__proto__.toString'), undefined);
assert.strictEqual(apos.util.get({ a: 1 }, '__proto__'), undefined);
});

it('implementPatchOperators must not let a $pullAll key pollute Object.prototype', function() {
// The reported attack vector: an authenticated editor PATCH body of
// { $pullAll: { '__proto__.publicApiProjection': [] } } reached
// apos.util.set with a fully attacker-controlled key.
const patch = {
$pullAll: {
'__proto__.publicApiProjection': []
}
};
try {
assert.throws(() => {
apos.schema.implementPatchOperators(patch, {});
}, { name: 'invalid' });
assert.strictEqual({}.publicApiProjection, undefined);
} finally {
delete Object.prototype.publicApiProjection;
}
});

it('implementPatchOperators must not let a direct dot-notation key pollute Object.prototype', function() {
// The second documented entry point: a top-level dotted key whose value
// is fully attacker-controlled.
const patch = {
'__proto__.publicApiProjection': { title: 1 }
};
try {
assert.throws(() => {
apos.schema.implementPatchOperators(patch, {});
}, { name: 'invalid' });
assert.strictEqual({}.publicApiProjection, undefined);
} finally {
delete Object.prototype.publicApiProjection;
}
});

it('should slugify', function () {
// Basic
assert.equal(
Expand Down
12 changes: 6 additions & 6 deletions packages/create-apostrophe/src/ui/links.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ const LINKS = Object.freeze({

/** Per-kit "get oriented" guides. `*-demo` and `*-demo-data` share a guide. */
const KIT_GUIDES = Object.freeze({
'apostrophe-astro-essentials': 'https://apostrophecms.com/guides/astro-essentials-overview',
'apostrophe-astro-demo': 'https://apostrophecms.com/guides/astro-demo-overview',
'apostrophe-astro-demo-data': 'https://apostrophecms.com/guides/astro-demo-overview',
'apostrophe-essentials': 'https://apostrophecms.com/guides/apostrophe-standalone-essentials-overview',
'apostrophe-demo': 'https://apostrophecms.com/guides/apostrophe-demo-overview',
'apostrophe-demo-data': 'https://apostrophecms.com/guides/apostrophe-demo-overview'
'apostrophe-astro-essentials': 'https://apostrophecms.com/docs/guide/astro-essentials-overview.html',
'apostrophe-astro-demo': 'https://apostrophecms.com/docs/guide/astro-demo-overview.html',
'apostrophe-astro-demo-data': 'https://apostrophecms.com/docs/guide/astro-demo-overview.html',
'apostrophe-essentials': 'https://apostrophecms.com/docs/guide/apostrophe-standalone-essentials-overview.html',
'apostrophe-demo': 'https://apostrophecms.com/docs/guides/apostrophe-demo-overview.html',
'apostrophe-demo-data': 'https://apostrophecms.com/docs/guide/apostrophe-demo-overview.html'
});

/** @typedef {keyof typeof LINKS} LinkName */
Expand Down
2 changes: 1 addition & 1 deletion packages/db-connect/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@apostrophecms/db-connect",
"version": "1.0.0",
"version": "1.0.1",
"description": "Database connection library and dump/restore tools for ApostropheCMS",
"license": "MIT",
"main": "index.js",
Expand Down
9 changes: 8 additions & 1 deletion packages/sanitize-html/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,14 @@ selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', '
// URL schemes we permit
allowedSchemes: [ 'http', 'https', 'ftp', 'mailto', 'tel' ],
allowedSchemesByTag: {},
allowedSchemesAppliedToAttributes: [ 'href', 'src', 'cite' ],
allowedSchemesAppliedToAttributes: [
'href', 'src', 'cite',
'action', 'formaction', 'data', 'xlink:href',
'poster', 'background', 'ping',
'longdesc', 'usemap', 'codebase', 'classid', 'archive',
'profile', 'manifest', 'itemid',
'dynsrc', 'lowsrc'
],
allowProtocolRelative: true,
enforceHtmlBoundary: false,
parseStyleAttributes: true
Expand Down
13 changes: 10 additions & 3 deletions packages/sanitize-html/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,11 +440,11 @@
return;
}
}
if (a === 'srcset') {
if (a === 'srcset' || a === 'imagesrcset') {
try {
let parsed = parseSrcset(value);
parsed.forEach(function(value) {
if (naughtyHref('srcset', value.url)) {
if (naughtyHref(a, value.url)) {
value.evil = true;
}
});
Expand Down Expand Up @@ -579,7 +579,7 @@
// double-encoding. Other "nonTextTags" like <option> are not raw text
// elements in htmlparser2, so their contents are decoded and must be
// escaped below like any other text (important to prevent XSS via
// entity-encoded payloads such as <option>&lt;script&gt;...&lt;/script&gt;</option>).

Check warning on line 582 in packages/sanitize-html/index.js

View workflow job for this annotation

GitHub Actions / standalone (26, n/a)

This line has a length of 94. Maximum allowed is 90

Check warning on line 582 in packages/sanitize-html/index.js

View workflow job for this annotation

GitHub Actions / standalone (24, n/a)

This line has a length of 94. Maximum allowed is 90

Check warning on line 582 in packages/sanitize-html/index.js

View workflow job for this annotation

GitHub Actions / standalone (22, n/a)

This line has a length of 94. Maximum allowed is 90
result += text;
} else if (!addedText) {
const escaped = escapeHtml(text, false);
Expand Down Expand Up @@ -957,7 +957,14 @@
// URL schemes we permit
allowedSchemes: [ 'http', 'https', 'ftp', 'mailto', 'tel' ],
allowedSchemesByTag: {},
allowedSchemesAppliedToAttributes: [ 'href', 'src', 'cite' ],
allowedSchemesAppliedToAttributes: [
'href', 'src', 'cite',
'action', 'formaction', 'data', 'xlink:href',
'poster', 'background', 'ping',
'longdesc', 'usemap', 'codebase', 'classid', 'archive',
'profile', 'manifest', 'itemid',
'dynsrc', 'lowsrc'
],
allowProtocolRelative: true,
enforceHtmlBoundary: false,
parseStyleAttributes: true,
Expand Down
Loading
Loading