fix(server): anchor plugin allowedHosts matching (#32560)

(cherry picked from commit 4acf935ba3ee7b6171e3965cb3ce47b2ba9bad1a)
This commit is contained in:
bo0tzz
2026-10-22 05:21:00 +03:00
committed by Codex
parent 564b720bce
commit 67152772d2
3 changed files with 49 additions and 15 deletions
@@ -31,6 +31,7 @@ import { AlbumService } from 'src/services/album.service.js';
import { AssetService } from 'src/services/asset.service.js';
import { BaseService } from 'src/services/base.service.js';
import { TagService } from 'src/services/tag.service.js';
import { isAllowedHost } from 'src/utils/workflow.js';
const dummy = () => {
throw new Error(
@@ -93,22 +94,18 @@ export class WorkflowExecutionService extends BaseService {
]
>(async (authDto, context, args) => {
const hostname = new URL(args[0]).hostname;
for (const pattern of context.allowedHosts) {
const regex = new RegExp(pattern.replaceAll('.', String.raw`\.`).replaceAll('*', '.*'));
if (regex.test(hostname)) {
// eslint-disable-next-line unicorn/no-invalid-argument-count
const res = await fetch(...args);
return {
ok: res.ok,
status: res.status,
body: await res.text(),
};
}
if (!isAllowedHost(hostname, context.allowedHosts)) {
throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest');
}
throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest');
// eslint-disable-next-line unicorn/no-invalid-argument-count
const res = await fetch(...args);
return {
ok: res.ok,
status: res.status,
body: await res.text(),
};
});
const bulkTagAssets = this.wrap<[dto: TagBulkAssetsDto]>((authDto, ctx, args) =>
tagService.bulkTagAssets(authDto, ...args),
+26 -1
View File
@@ -1,6 +1,6 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { WorkflowType } from 'src/enum.js';
import { isMethodCompatible } from 'src/utils/workflow.js';
import { isAllowedHost, isMethodCompatible } from 'src/utils/workflow.js';
const tests: Array<{ trigger: WorkflowTrigger; types: WorkflowType[]; expected: boolean }> = [
{
@@ -35,3 +35,28 @@ describe(isMethodCompatible.name, () => {
expect(isMethodCompatible({ types }, trigger)).toBe(expected);
});
});
describe(isAllowedHost.name, () => {
it('should match an exact host', () => {
expect(isAllowedHost('api.example.com', ['api.example.com'])).toBe(true);
expect(isAllowedHost('API.Example.com', ['api.example.com'])).toBe(true);
});
it('should not match a host that merely contains the pattern', () => {
expect(isAllowedHost('api.example.com.attacker.net', ['api.example.com'])).toBe(false);
expect(isAllowedHost('notapi.example.com', ['api.example.com'])).toBe(false);
expect(isAllowedHost('apixexample.com', ['api.example.com'])).toBe(false);
});
it('should support wildcards', () => {
expect(isAllowedHost('a.example.com', ['*.example.com'])).toBe(true);
expect(isAllowedHost('a.b.example.com', ['*.example.com'])).toBe(true);
expect(isAllowedHost('example.com', ['*.example.com'])).toBe(false);
expect(isAllowedHost('example.com.attacker.net', ['*.example.com'])).toBe(false);
expect(isAllowedHost('anything.at.all', ['*'])).toBe(true);
});
it('should reject everything when no hosts are listed', () => {
expect(isAllowedHost('example.com', [])).toBe(false);
});
});
+12
View File
@@ -69,3 +69,15 @@ export const parseMethodString = (method: string) => {
const methodName = matches.groups?.method;
return { pluginName, version, methodName };
};
export const isAllowedHost = (hostname: string, allowedHosts: string[]) => {
const target = hostname.toLowerCase();
return allowedHosts.some((pattern) => {
const source = pattern
.toLowerCase()
.split('*')
.map((part) => part.replaceAll(/[$()+.?[\\\]^{|}]/g, String.raw`\$&`))
.join('.*');
return new RegExp(`^${source}$`).test(target);
});
};