Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/beasties/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ All optional. Pass them to `new Beasties({ ... })`.
- `path` **[String](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** Base path location of the CSS files _(default: `''`)_
- `publicPath` **[String](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** Public path of the CSS resources. This prefix is removed from the href _(default: `''`)_
- `external` **[Boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** Inline styles from external stylesheets _(default: `true`)_
- `remote` **[Boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** Download and inline remote stylesheets (http://, https://, //) _(default: `false`)_
- `inlineThreshold` **[Number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** Inline external stylesheets smaller than a given size _(default: `0`)_
- `minimumExternalSize` **[Number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** If the non-critical external stylesheet would be below this size, just inline it _(default: `0`)_
- `pruneSource` **[Boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** Remove inlined rules from the external stylesheet _(default: `false`)_
Expand Down
1 change: 1 addition & 0 deletions packages/beasties/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface Options {
path?: string
publicPath?: string
external?: boolean
remote?: boolean
inlineThreshold?: number
minimumExternalSize?: number
pruneSource?: boolean
Expand Down
28 changes: 25 additions & 3 deletions packages/beasties/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,28 @@ export default class Beasties {
.replace(/^\//, '')
}

// Ignore remote stylesheets
if (/^https?:\/\//.test(normalizedPath) || href.startsWith('//')) {
// Handle remote stylesheets
const isRemote = /^https?:\/\//.test(href) || href.startsWith('//')
if (isRemote) {
if (this.options.remote === true) {
try {
// Normalize protocol-relative URLs
const absoluteUrl = href.startsWith('//') ? `https:${href}` : href

const response = await fetch(absoluteUrl)

if (!response.ok) {
this.logger.warn?.(`Failed to fetch ${absoluteUrl} (${response.status})`)
return undefined
}

return await response.text()
}
catch (error) {
this.logger.warn?.(`Error fetching ${href}: ${(error as Error).message}`)
return undefined
}
}
return undefined
}
Comment on lines +208 to 231
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Document the security implications of enabling remote stylesheet fetching.

The implementation correctly handles remote stylesheets with proper error handling. However, when remote: true is enabled with untrusted HTML input, this could allow Server-Side Request Forgery (SSRF) attacks by making requests to internal network resources.

Consider adding a security note to the documentation:

In packages/beasties/README.md, add a security section:

### Security Considerations

**Remote Stylesheet Fetching**

The `remote` option enables fetching stylesheets from external URLs. Only enable this option if:
- The HTML input is from a trusted source
- You have validated the URLs in the HTML
- Your application is not exposed to SSRF attacks via internal network access

For untrusted HTML input, consider implementing URL validation or an allowlist of permitted domains.

Alternatively, consider implementing URL validation directly in the code:

// Add validation before fetching
const url = new URL(absoluteUrl)
if (url.hostname === 'localhost' || url.hostname.startsWith('127.') || url.hostname.startsWith('192.168.')) {
  this.logger.warn?.(`Blocked internal URL: ${absoluteUrl}`)
  return undefined
}
🤖 Prompt for AI Agents
In packages/beasties/src/index.ts around lines 184 to 207, the remote stylesheet
fetch path allows SSRF when remote: true; update the code to validate
absoluteUrl before fetching (parse with URL and block or warn+return for
internal hosts/IPs such as localhost, 127.*, 10.*, 172.16-31.*, 192.168.* and
optionally support a configurable allowlist of safe hostnames), and log blocked
URLs; additionally, add a Security Considerations section to
packages/beasties/README.md documenting the risks of enabling remote:true,
recommending only using it for trusted HTML, validating URLs or using an
allowlist, and describing the behavior when a URL is blocked.


Expand Down Expand Up @@ -275,7 +295,9 @@ export default class Beasties {
const href = link.getAttribute('href')

// skip filtered resources, or network resources if no filter is provided
if (!href?.endsWith('.css')) {
// Strip query params and hashes before checking extension
const pathname = href?.split('?')[0]?.split('#')[0]
if (!pathname?.endsWith('.css')) {
return undefined
}

Expand Down
4 changes: 4 additions & 0 deletions packages/beasties/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export interface Options {
* Inline styles from external stylesheets _(default: `true`)_
*/
external?: boolean
/**
* Download and inline remote stylesheets _(default: `false`)_
*/
remote?: boolean
/**
* Inline external stylesheets smaller than a given size _(default: `0`)_
*/
Expand Down
154 changes: 154 additions & 0 deletions packages/beasties/test/beasties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,4 +497,158 @@ describe('beasties', () => {
expect(loggerWarnSpy).not.toHaveBeenCalled()
expect(result).toMatchSnapshot()
})

it('ignores remote stylesheets by default', async () => {
const beasties = new Beasties({
reduceInlineStyles: false,
})

const result = await beasties.process(trim`
<html>
<head>
<link rel="stylesheet" href="https://example.com/style.css">
</head>
<body>
<h1>Hello World!</h1>
</body>
</html>
`)

// Should not contain inlined critical CSS since remote is disabled
expect(result).not.toContain('<style>')
expect(result).toContain('https://example.com/style.css')
})

it('fetches remote stylesheets when remote: true', async () => {
const originalFetch = globalThis.fetch
try {
const beasties = new Beasties({
reduceInlineStyles: false,
remote: true,
})

// Mock fetch
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
text: async () => 'h1 { color: blue; } h2.unused { color: red; }',
})
globalThis.fetch = mockFetch as any

const result = await beasties.process(trim`
<html>
<head>
<link rel="stylesheet" href="https://example.com/style.css">
</head>
<body>
<h1>Hello World!</h1>
</body>
</html>
`)

expect(mockFetch).toHaveBeenCalledWith('https://example.com/style.css')
expect(result).toContain('<style>h1{color:blue}</style>')
expect(result).toContain('https://example.com/style.css')
}
finally {
globalThis.fetch = originalFetch
}
})

it('handles protocol-relative URLs when remote: true', async () => {
const originalFetch = globalThis.fetch
try {
const beasties = new Beasties({
reduceInlineStyles: false,
remote: true,
})

const mockFetch = vi.fn().mockResolvedValue({
ok: true,
text: async () => 'h1 { color: green; }',
})
globalThis.fetch = mockFetch as any

const result = await beasties.process(trim`
<html>
<head>
<link rel="stylesheet" href="//example.com/style.css">
</head>
<body>
<h1>Hello World!</h1>
</body>
</html>
`)

expect(mockFetch).toHaveBeenCalledWith('https://example.com/style.css')
expect(result).toContain('<style>h1{color:green}</style>')
}
finally {
globalThis.fetch = originalFetch
}
})

it('handles fetch 404 responses gracefully', async () => {
const originalFetch = globalThis.fetch
try {
const beasties = new Beasties({
reduceInlineStyles: false,
remote: true,
})

const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
})
globalThis.fetch = mockFetch as any

const result = await beasties.process(trim`
<html>
<head>
<link rel="stylesheet" href="https://example.com/missing.css">
</head>
<body>
<h1>Hello World!</h1>
</body>
</html>
`)

expect(mockFetch).toHaveBeenCalledWith('https://example.com/missing.css')
// Should still produce valid HTML, just without inlined styles
expect(result).toContain('<h1>Hello World!</h1>')
}
finally {
globalThis.fetch = originalFetch
}
})

it('handles fetch network errors gracefully', async () => {
const originalFetch = globalThis.fetch
try {
const beasties = new Beasties({
reduceInlineStyles: false,
remote: true,
})

const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
globalThis.fetch = mockFetch as any

const result = await beasties.process(trim`
<html>
<head>
<link rel="stylesheet" href="https://example.com/style.css">
</head>
<body>
<h1>Hello World!</h1>
</body>
</html>
`)

expect(mockFetch).toHaveBeenCalledWith('https://example.com/style.css')
// Should still produce valid HTML, just without inlined styles
expect(result).toContain('<h1>Hello World!</h1>')
}
finally {
globalThis.fetch = originalFetch
}
})
})