mirror of
https://codeberg.org/yeentown/barkey.git
synced 2025-12-11 09:28:24 +00:00
validate all URLs before fetch
This commit is contained in:
parent
51ad31b5a4
commit
982223ad38
7 changed files with 101 additions and 73 deletions
|
|
@ -17,7 +17,8 @@ import { StatusError } from '@/misc/status-error.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||
import type { IObject, IObjectWithId } from '@/core/activitypub/type.js';
|
||||
import { ApUtilityService } from './activitypub/ApUtilityService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
||||
import type { Response } from 'node-fetch';
|
||||
import type { URL } from 'node:url';
|
||||
import type { Socket } from 'node:net';
|
||||
|
|
@ -132,6 +133,7 @@ export class HttpRequestService {
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
private readonly apUtilityService: ApUtilityService,
|
||||
private readonly utilityService: UtilityService,
|
||||
) {
|
||||
const cache = new CacheableLookup({
|
||||
maxTtl: 3600, // 1hours
|
||||
|
|
@ -236,8 +238,6 @@ export class HttpRequestService {
|
|||
|
||||
@bindThis
|
||||
public async getActivityJson(url: string, isLocalAddressAllowed = false, allowAnonymous = false): Promise<IObjectWithId> {
|
||||
this.apUtilityService.assertApUrl(url);
|
||||
|
||||
const res = await this.send(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
|
@ -311,6 +311,8 @@ export class HttpRequestService {
|
|||
): Promise<Response> {
|
||||
const timeout = args.timeout ?? 5000;
|
||||
|
||||
this.utilityService.assertUrl(url);
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import psl from 'psl';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MiMeta } from '@/models/Meta.js';
|
||||
import { MiMeta, SoftwareSuspension } from '@/models/Meta.js';
|
||||
import { MiInstance } from '@/models/Instance.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { EnvService } from '@/core/EnvService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UtilityService {
|
||||
|
|
@ -20,6 +23,8 @@ export class UtilityService {
|
|||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
private readonly envService: EnvService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -181,8 +186,8 @@ export class UtilityService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public punyHostPSLDomain(url: string): string {
|
||||
const urlObj = new URL(url);
|
||||
public punyHostPSLDomain(url: string | URL): string {
|
||||
const urlObj = typeof(url) === 'object' ? url : new URL(url);
|
||||
const hostname = urlObj.hostname;
|
||||
const domain = this.specialSuffix(hostname) ?? psl.get(hostname) ?? hostname;
|
||||
const host = `${this.toPuny(domain)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
|
||||
|
|
@ -213,4 +218,52 @@ export class UtilityService {
|
|||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a provided URL is in a format acceptable for federation.
|
||||
* @throws {IdentifiableError} If URL cannot be parsed
|
||||
* @throws {IdentifiableError} If URL is not HTTPS
|
||||
* @throws {IdentifiableError} If URL contains credentials
|
||||
*/
|
||||
@bindThis
|
||||
public assertUrl(url: string | URL): URL | never {
|
||||
// If string, parse and validate
|
||||
if (typeof(url) === 'string') {
|
||||
try {
|
||||
url = new URL(url);
|
||||
} catch {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid url ${url}: not a valid URL`);
|
||||
}
|
||||
}
|
||||
|
||||
// Must be HTTPS
|
||||
if (!this.checkHttps(url)) {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid url ${url}: unsupported protocol ${url.protocol}`);
|
||||
}
|
||||
|
||||
// Must not have credentials
|
||||
if (url.username || url.password) {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid url ${url}: contains embedded credentials`);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the URL contains HTTPS.
|
||||
* Additionally, allows HTTP in non-production environments.
|
||||
* Based on check-https.ts.
|
||||
*/
|
||||
@bindThis
|
||||
public checkHttps(url: string | URL): boolean {
|
||||
const isNonProd = this.envService.env.NODE_ENV !== 'production';
|
||||
|
||||
try {
|
||||
const proto = new URL(url).protocol;
|
||||
return proto === 'https:' || (proto === 'http:' && isNonProd);
|
||||
} catch {
|
||||
// Invalid URLs don't "count" as HTTPS
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,8 +157,6 @@ export class ApRequestService {
|
|||
|
||||
@bindThis
|
||||
public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, digest?: string): Promise<void> {
|
||||
this.apUtilityService.assertApUrl(url);
|
||||
|
||||
const body = typeof object === 'string' ? object : JSON.stringify(object);
|
||||
|
||||
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
||||
|
|
@ -191,8 +189,6 @@ export class ApRequestService {
|
|||
*/
|
||||
@bindThis
|
||||
public async signedGet(url: string, user: { id: MiUser['id'] }, allowAnonymous = false, followAlternate?: boolean): Promise<IObjectWithId> {
|
||||
this.apUtilityService.assertApUrl(url);
|
||||
|
||||
const _followAlternate = followAlternate ?? true;
|
||||
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,12 @@ import { Injectable } from '@nestjs/common';
|
|||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
import { EnvService } from '@/core/EnvService.js';
|
||||
import { getApId, getOneApHrefNullable, IObject } from './type.js';
|
||||
import { getApId, getOneApHrefNullable, IObject } from '@/core/activitypub/type.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApUtilityService {
|
||||
constructor(
|
||||
private readonly utilityService: UtilityService,
|
||||
private readonly envService: EnvService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -39,8 +37,11 @@ export class ApUtilityService {
|
|||
public haveSameAuthority(url1: string, url2: string): boolean {
|
||||
if (url1 === url2) return true;
|
||||
|
||||
const authority1 = this.utilityService.punyHostPSLDomain(url1);
|
||||
const authority2 = this.utilityService.punyHostPSLDomain(url2);
|
||||
const parsed1 = this.utilityService.assertUrl(url1);
|
||||
const parsed2 = this.utilityService.assertUrl(url2);
|
||||
|
||||
const authority1 = this.utilityService.punyHostPSLDomain(parsed1);
|
||||
const authority2 = this.utilityService.punyHostPSLDomain(parsed2);
|
||||
return authority1 === authority2;
|
||||
}
|
||||
|
||||
|
|
@ -63,12 +64,16 @@ export class ApUtilityService {
|
|||
: undefined,
|
||||
}))
|
||||
.filter(({ url, type }) => {
|
||||
if (!url) return false;
|
||||
if (!this.checkHttps(url)) return false;
|
||||
if (!isAcceptableUrlType(type)) return false;
|
||||
try {
|
||||
if (!url) return false;
|
||||
if (!isAcceptableUrlType(type)) return false;
|
||||
const parsed = this.utilityService.assertUrl(url);
|
||||
|
||||
const urlAuthority = this.utilityService.punyHostPSLDomain(url);
|
||||
return urlAuthority === targetAuthority;
|
||||
const urlAuthority = this.utilityService.punyHostPSLDomain(parsed);
|
||||
return urlAuthority === targetAuthority;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return rankUrlType(a.type) - rankUrlType(b.type);
|
||||
|
|
@ -76,44 +81,6 @@ export class ApUtilityService {
|
|||
|
||||
return acceptableUrls[0]?.url ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a provided URL is in a format acceptable for federation.
|
||||
* @throws {IdentifiableError} If URL cannot be parsed
|
||||
* @throws {IdentifiableError} If URL is not HTTPS
|
||||
*/
|
||||
public assertApUrl(url: string | URL): void {
|
||||
// If string, parse and validate
|
||||
if (typeof(url) === 'string') {
|
||||
try {
|
||||
url = new URL(url);
|
||||
} catch {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: not a valid URL`);
|
||||
}
|
||||
}
|
||||
|
||||
// Must be HTTPS
|
||||
if (!this.checkHttps(url)) {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: unsupported protocol ${url.protocol}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the URL contains HTTPS.
|
||||
* Additionally, allows HTTP in non-production environments.
|
||||
* Based on check-https.ts.
|
||||
*/
|
||||
private checkHttps(url: string | URL): boolean {
|
||||
const isNonProd = this.envService.env.NODE_ENV !== 'production';
|
||||
|
||||
try {
|
||||
const proto = new URL(url).protocol;
|
||||
return proto === 'https:' || (proto === 'http:' && isNonProd);
|
||||
} catch {
|
||||
// Invalid URLs don't "count" as HTTPS
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isAcceptableUrlType(type: string | undefined): boolean {
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export class ApNoteService {
|
|||
actor?: MiRemoteUser,
|
||||
user?: MiRemoteUser,
|
||||
): Error | null {
|
||||
this.apUtilityService.assertApUrl(uri);
|
||||
this.utilityService.assertUrl(uri);
|
||||
const expectHost = this.utilityService.extractDbHost(uri);
|
||||
const apType = getApType(object);
|
||||
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
*/
|
||||
@bindThis
|
||||
private validateActor(x: IObject, uri: string): IActor {
|
||||
this.apUtilityService.assertApUrl(uri);
|
||||
this.utilityService.assertUrl(uri);
|
||||
const expectHost = this.utilityService.punyHostPSLDomain(uri);
|
||||
|
||||
if (!isActor(x)) {
|
||||
|
|
@ -170,7 +170,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox type`);
|
||||
}
|
||||
|
||||
this.apUtilityService.assertApUrl(x.inbox);
|
||||
this.utilityService.assertUrl(x.inbox);
|
||||
const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox);
|
||||
if (inboxHost !== expectHost) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox host ${inboxHost}`);
|
||||
|
|
@ -179,7 +179,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
|
||||
if (sharedInboxObject != null) {
|
||||
const sharedInbox = getApId(sharedInboxObject);
|
||||
this.apUtilityService.assertApUrl(sharedInbox);
|
||||
this.utilityService.assertUrl(sharedInbox);
|
||||
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong shared inbox ${sharedInbox}`);
|
||||
}
|
||||
|
|
@ -190,7 +190,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
if (xCollection != null) {
|
||||
const collectionUri = getApId(xCollection);
|
||||
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
|
||||
this.apUtilityService.assertApUrl(collectionUri);
|
||||
this.utilityService.assertUrl(collectionUri);
|
||||
if (this.utilityService.punyHostPSLDomain(collectionUri) !== expectHost) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} host ${collectionUri}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,30 +3,40 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { UtilityService } from '@/core/UtilityService.js';
|
||||
import type { IObject } from '@/core/activitypub/type.js';
|
||||
import type { EnvService } from '@/core/EnvService.js';
|
||||
import type { MiMeta } from '@/models/Meta.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
|
||||
describe(ApUtilityService, () => {
|
||||
let serviceUnderTest: ApUtilityService;
|
||||
let env: Record<string, string>;
|
||||
|
||||
beforeEach(() => {
|
||||
const utilityService = {
|
||||
punyHostPSLDomain(input: string) {
|
||||
const host = new URL(input).host;
|
||||
const parts = host.split('.');
|
||||
return `${parts[parts.length - 2]}.${parts[parts.length - 1]}`;
|
||||
},
|
||||
} as unknown as UtilityService;
|
||||
|
||||
env = {};
|
||||
const envService = {
|
||||
env,
|
||||
} as unknown as EnvService;
|
||||
|
||||
serviceUnderTest = new ApUtilityService(utilityService, envService);
|
||||
const config = {
|
||||
host: 'example.com',
|
||||
blockedHosts: [],
|
||||
silencedHosts: [],
|
||||
mediaSilencedHosts: [],
|
||||
federationHosts: [],
|
||||
bubbleInstances: [],
|
||||
deliverSuspendedSoftware: [],
|
||||
federation: 'all',
|
||||
} as unknown as Config;
|
||||
const meta = {
|
||||
|
||||
} as MiMeta;
|
||||
|
||||
const utilityService = new UtilityService(config, meta, envService);
|
||||
|
||||
serviceUnderTest = new ApUtilityService(utilityService);
|
||||
});
|
||||
|
||||
describe('assertIdMatchesUrlAuthority', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue