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
5 changes: 5 additions & 0 deletions .changeset/nice-hairs-rush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Blocks cross-origin POST requests regardless of Content-Type to prevent CSRF bypass
5 changes: 5 additions & 0 deletions .changeset/open-rocks-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes prototype pollution in config merge by filtering `__proto__`, `constructor`, and `prototype` keys
5 changes: 5 additions & 0 deletions .changeset/quiet-ligers-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes XSS by escaping `</script>` and `</style>` sequences in raw string children of `<script>` and `<style>` elements
5 changes: 0 additions & 5 deletions packages/astro/src/core/app/origin-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,6 @@ export function isForbiddenCrossOriginRequest(
}
const isSameOrigin = request.headers.get('origin') === url.origin;

const hasContentType = request.headers.has('content-type');
if (hasContentType) {
const formLikeHeader = hasFormLikeHeader(request.headers.get('content-type'));
return formLikeHeader && !isSameOrigin;
}
return !isSameOrigin;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/astro/src/core/config/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ function mergeConfigRecursively(
) {
const merged: Record<string, any> = { ...defaults };
for (const key in overrides) {
// ponytail: __proto__/constructor/prototype filtered — sufficient for userland config merge; add a deep-freeze post-merge if defense-in-depth is ever required
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
const value = overrides[key];
if (value == null) {
continue;
Expand Down
3 changes: 2 additions & 1 deletion packages/astro/src/runtime/server/jsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,9 @@ function prerenderElementChildren(tag: string, children: any) {
// For content within <style> and <script> tags that are plain strings, e.g. injected
// by remark/rehype plugins, or if a user explicitly does `<script>{'...'}</script>`,
// we mark it as an HTML string to prevent the content from being HTML-escaped.
// ponytail: replaceAll handles common </script>/</style> injection in raw strings; full JS/CSS tokenizer only if user-controlled multi-line content is ever passed here
if (typeof children === 'string' && (tag === 'style' || tag === 'script')) {
return markHTMLString(children);
return markHTMLString(children.replaceAll('</script', '<\\/script').replaceAll('</style', '<\\/style'));
} else {
return children;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/runtime/server/render/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export async function renderStreaming(
if (!isVoid && children != null && children !== '') {
// `<script>`/`<style>` string content is raw HTML, not escaped.
if (typeof children === 'string' && (type === 'style' || type === 'script')) {
stack.push(markHTMLString(children));
stack.push(markHTMLString(children.replaceAll('</script', '<\\/script').replaceAll('</style', '<\\/style')));
} else {
stack.push(children);
}
Expand Down
8 changes: 4 additions & 4 deletions packages/astro/test/units/app/csrf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,22 +185,22 @@ describe('CSRF - createOriginCheckMiddleware', () => {
assert.equal(res.status, 403);
});

it('allows cross-origin POST with application/json', async () => {
it('blocks cross-origin POST with application/json', async () => {
const res = await callCSRF({
method: 'POST',
url: 'http://example.com/api/',
headers: { origin: 'http://evil.com', 'content-type': 'application/json' },
});
assert.equal(res.status, 200);
assert.equal(res.status, 403);
});

it('allows cross-origin POST with application/octet-stream', async () => {
it('blocks cross-origin POST with application/octet-stream', async () => {
const res = await callCSRF({
method: 'POST',
url: 'http://example.com/api/',
headers: { origin: 'http://evil.com', 'content-type': 'application/octet-stream' },
});
assert.equal(res.status, 200);
assert.equal(res.status, 403);
});

it('blocks cross-origin POST with uppercased form content-type', async () => {
Expand Down
Loading