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
50 changes: 43 additions & 7 deletions src/lib/server/AssetGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,38 @@ import { is, serialize } from '@astrojs/compiler/utils';
export class AssetGateway {
private cacheDir: string;

/**
* Allowlist of hostname patterns for asset fetching.
* Only HTTPS URLs matching these patterns are permitted.
* Expand as needed for additional CDNs used in Stitch designs.
*/
private static ALLOWED_HOST_PATTERNS: RegExp[] = [
/\.googleapis\.com$/,
/\.googleusercontent\.com$/,
/\.gstatic\.com$/,
/^cdnjs\.cloudflare\.com$/,
];

/**
* Validates that a URL is safe to fetch:
* - Must be HTTPS
* - Hostname must match the allowlist
*/
static validateAssetUrl(url: string): boolean {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}

if (parsed.protocol !== 'https:') {
return false;
}

return AssetGateway.ALLOWED_HOST_PATTERNS.some(pattern => pattern.test(parsed.hostname));
}

constructor(projectRoot: string = process.cwd()) {
this.cacheDir = path.join(projectRoot, '.stitch-mcp', 'cache');
}
Expand All @@ -23,6 +55,12 @@ export class AssetGateway {

async fetchAsset(url: string): Promise<{ stream: Readable; contentType?: string } | null> {
await this.init();

if (!AssetGateway.validateAssetUrl(url)) {
console.warn(`Blocked asset fetch for disallowed URL: ${url}`);
return null;
}

const hash = this.getHash(url);
const cachePath = path.join(this.cacheDir, hash);
const metadataPath = cachePath + '.meta.json';
Expand Down Expand Up @@ -152,10 +190,10 @@ export class AssetGateway {
},
);

// Optimistic prefetch for discovered URLs
for (const url of discovered) {
this.fetchAsset(url).catch(() => {});
}
// Optimistic prefetch: fire-and-forget parallel cache warming.
// rewriteCssUrls is synchronous; prefetching is a side-effect that does
// not need to complete before the rewritten CSS is returned.
Promise.all(discovered.map(url => this.fetchAsset(url).catch(() => {})));

return rewritten;
}
Expand All @@ -177,9 +215,7 @@ export class AssetGateway {
$('script').each((_, el) => process(el, 'src'));

// Optimistic fetch
for (const url of assets) {
this.fetchAsset(url).catch(console.error);
}
await Promise.all(Array.from(assets).map(url => this.fetchAsset(url).catch(console.error)));

return $.html();
}
Expand Down
5 changes: 2 additions & 3 deletions src/lib/server/vite/plugins/virtualContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ export function virtualContent({ assetGateway, htmlMap }: VirtualContentOptions)
// Buffer CSS to rewrite url() references for sub-resources (fonts, images)
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
stream.on('end', () => {
stream.on('end', async () => {
const css = Buffer.concat(chunks).toString('utf-8');
const rewritten = assetGateway.rewriteCssUrls(css, assetUrl);
const rewritten = await assetGateway.rewriteCssUrls(css, assetUrl);
res.end(rewritten);
});
stream.on('error', (err) => {
Expand Down Expand Up @@ -211,4 +211,3 @@ export function virtualContent({ assetGateway, htmlMap }: VirtualContentOptions)
}
};
}

Loading