Skip to content

Commit 3c85c95

Browse files
committed
Add allowedDomains check for RemoteAsset theme checker
Enhances developer experience by validating the remote assets (scripts, stylesheets) are loaded not only from Shopify CDNs but now approved domains. This prevents potential performance risks and incorrect errors from loading resources from untrusted sources, forcing developers to go and enable a source if it's _required_ The implementation includes: - Schema property for configuring allowedDomains - Domain validation against configured allow list - Test coverage for both allowed and non-allowed domains Reverted minor changeset to patch bump Fixed prettier issues Refactor domain list from global to parameter Changes domain validation to accept allowedDomains as a parameter instead of mutating a global array. This improves code clarity and testability by making data flow explicit. The change reduces side effets by: - Removing relince on shared mutable state - Making dpendencies clear through function signatures - Enabling better unit testing of domain validation logic Added file formatting Add normalisaztion & tests. - Added normalization to domains - Added extra tests to test against and for normalisation of strings. Update domains in test
1 parent fced038 commit 3c85c95

File tree

3 files changed

+123
-11
lines changed

3 files changed

+123
-11
lines changed

.changeset/spicy-pumas-sing.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/theme-check-common': patch
3+
---
4+
5+
Add RemoteAsset allowedDomains check to validate CDN and approved domain usage for better performance and developer experience

packages/theme-check-common/src/checks/remote-asset/index.spec.ts

+69-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { runLiquidCheck, highlightedOffenses } from '../../test';
2+
import { runLiquidCheck, highlightedOffenses, check, MockTheme } from '../../test';
33
import { RemoteAsset } from './index';
44

55
describe('Module: RemoteAsset', () => {
@@ -209,4 +209,72 @@ describe('Module: RemoteAsset', () => {
209209
const highlights = highlightedOffenses({ 'file.liquid': sourceCode }, offenses);
210210
expect(highlights).to.be.empty;
211211
});
212+
213+
it('should report an offense if url is not listed in allowedDomains', async () => {
214+
const themeFiles: MockTheme = {
215+
'layout/theme.liquid': `
216+
<script src="https://domain.com" defer></script>
217+
`,
218+
};
219+
220+
const offenses = await check(
221+
themeFiles,
222+
[RemoteAsset],
223+
{},
224+
{
225+
RemoteAsset: {
226+
enabled: true,
227+
allowedDomains: ['someotherdomain.com'],
228+
},
229+
},
230+
);
231+
232+
expect(offenses).to.have.length(1);
233+
});
234+
235+
it('should report an offense if the url in the config is malformed/missing protocol', async () => {
236+
const themeFiles: MockTheme = {
237+
'layout/theme.liquid': `
238+
<script src="https://domain.com" defer></script>
239+
<script src="https://www.domain.com" defer></script>
240+
<script src="www.domain.com" defer></script>
241+
`,
242+
};
243+
244+
const offenses = await check(
245+
themeFiles,
246+
[RemoteAsset],
247+
{},
248+
{
249+
RemoteAsset: {
250+
enabled: true,
251+
allowedDomains: ['www.domain.com', 'domain.com'],
252+
},
253+
},
254+
);
255+
256+
expect(offenses).to.have.length(2);
257+
});
258+
259+
it('should not report an offense if url is listed in allowedDomains', async () => {
260+
const themeFiles: MockTheme = {
261+
'layout/theme.liquid': `
262+
<script src="https://domain.com" defer></script>
263+
`,
264+
};
265+
266+
const offenses = await check(
267+
themeFiles,
268+
[RemoteAsset],
269+
{},
270+
{
271+
RemoteAsset: {
272+
enabled: true,
273+
allowedDomains: ['https://domain.com', 'http://domain.com', 'https://www.domain.com'],
274+
},
275+
},
276+
);
277+
278+
expect(offenses).to.be.empty;
279+
});
212280
});

packages/theme-check-common/src/checks/remote-asset/index.ts

+49-10
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
SourceCodeType,
1313
LiquidCheckDefinition,
1414
LiquidHtmlNode,
15+
SchemaProp,
1516
} from '../../types';
1617
import { isAttr, isValuedHtmlAttribute, isNodeOfType, ValuedHtmlAttribute } from '../utils';
1718
import { last } from '../../utils';
@@ -42,15 +43,18 @@ function isLiquidVariable(node: LiquidHtmlNode | string): node is LiquidVariable
4243
return typeof node !== 'string' && node.type === NodeTypes.LiquidVariable;
4344
}
4445

45-
function isUrlHostedbyShopify(url: string): boolean {
46+
function isUrlHostedbyShopify(url: string, allowedDomains: string[] = []): boolean {
4647
const urlObj = new URL(url);
47-
return SHOPIFY_CDN_DOMAINS.includes(urlObj.hostname);
48+
return [...SHOPIFY_CDN_DOMAINS, ...allowedDomains].includes(urlObj.hostname);
4849
}
4950

50-
function valueIsDefinitelyNotShopifyHosted(attr: ValuedHtmlAttribute): boolean {
51+
function valueIsDefinitelyNotShopifyHosted(
52+
attr: ValuedHtmlAttribute,
53+
allowedDomains: string[] = [],
54+
): boolean {
5155
return attr.value.some((node) => {
5256
if (node.type === NodeTypes.TextNode && /^(https?:)?\/\//.test(node.value)) {
53-
if (!isUrlHostedbyShopify(node.value)) {
57+
if (!isUrlHostedbyShopify(node.value, allowedDomains)) {
5458
return true;
5559
}
5660
}
@@ -60,7 +64,7 @@ function valueIsDefinitelyNotShopifyHosted(attr: ValuedHtmlAttribute): boolean {
6064
if (isLiquidVariable(variable)) {
6165
const expression = variable.expression;
6266
if (expression.type === NodeTypes.String && /^https?:\/\//.test(expression.value)) {
63-
if (!isUrlHostedbyShopify(expression.value)) {
67+
if (!isUrlHostedbyShopify(expression.value, allowedDomains)) {
6468
return true;
6569
}
6670
}
@@ -96,7 +100,34 @@ function valueIsShopifyHosted(attr: ValuedHtmlAttribute): boolean {
96100
});
97101
}
98102

99-
export const RemoteAsset: LiquidCheckDefinition = {
103+
// Takes a list of allowed domains, and normalises them into an expected domain: www.domain.com -> domain.com for equality checks.
104+
function normaliseAllowedDomains(allowedDomains: string[]): string[] {
105+
const urlRegex = new RegExp(
106+
/^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/,
107+
);
108+
return allowedDomains
109+
.map((domain) => {
110+
if (urlRegex.test(domain) === false) {
111+
return undefined;
112+
}
113+
114+
try {
115+
const url = new URL(domain);
116+
// Hostname can still return www. from https://www.domain.com we want it to be https://www.domain.com -> domain.com
117+
return url.hostname.replace(/www\./, '');
118+
} catch (_error) {
119+
// we shouldn't return the malformed domain - should be strict and stick to web standards (new URL validation).
120+
return undefined;
121+
}
122+
})
123+
.filter((domain): domain is string => domain !== undefined);
124+
}
125+
126+
const schema = {
127+
allowedDomains: SchemaProp.array(SchemaProp.string()).optional(),
128+
};
129+
130+
export const RemoteAsset: LiquidCheckDefinition<typeof schema> = {
100131
meta: {
101132
code: 'RemoteAsset',
102133
aliases: ['AssetUrlFilters'],
@@ -108,11 +139,13 @@ export const RemoteAsset: LiquidCheckDefinition = {
108139
},
109140
type: SourceCodeType.LiquidHtml,
110141
severity: Severity.WARNING,
111-
schema: {},
142+
schema,
112143
targets: [],
113144
},
114145

115146
create(context) {
147+
const allowedDomains = normaliseAllowedDomains(context.settings.allowedDomains || []);
148+
116149
function checkHtmlNode(node: HtmlVoidElement | HtmlRawNode) {
117150
if (!RESOURCE_TAGS.includes(node.name)) return;
118151

@@ -124,11 +157,14 @@ export const RemoteAsset: LiquidCheckDefinition = {
124157

125158
const isShopifyUrl = urlAttribute.value
126159
.filter((node): node is TextNode => node.type === NodeTypes.TextNode)
127-
.some((textNode) => isUrlHostedbyShopify(textNode.value));
160+
.some((textNode) => isUrlHostedbyShopify(textNode.value, allowedDomains));
128161

129162
if (isShopifyUrl) return;
130163

131-
const hasDefinitelyARemoteAssetUrl = valueIsDefinitelyNotShopifyHosted(urlAttribute);
164+
const hasDefinitelyARemoteAssetUrl = valueIsDefinitelyNotShopifyHosted(
165+
urlAttribute,
166+
allowedDomains,
167+
);
132168
if (hasDefinitelyARemoteAssetUrl) {
133169
context.report({
134170
message: 'Asset should be served by the Shopify CDN for better performance.',
@@ -167,7 +203,10 @@ export const RemoteAsset: LiquidCheckDefinition = {
167203
if (hasAsset) return;
168204

169205
const urlNode = parentNode.expression;
170-
if (urlNode.type === NodeTypes.String && !isUrlHostedbyShopify(urlNode.value)) {
206+
if (
207+
urlNode.type === NodeTypes.String &&
208+
!isUrlHostedbyShopify(urlNode.value, allowedDomains)
209+
) {
171210
context.report({
172211
message: 'Asset should be served by the Shopify CDN for better performance.',
173212
startIndex: urlNode.position.start,

0 commit comments

Comments
 (0)