File size: 3,623 Bytes
45d1682
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12ba1bc
45d1682
12ba1bc
 
 
45d1682
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12ba1bc
45d1682
 
 
 
 
12ba1bc
 
 
 
45d1682
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
import { singleton } from 'tsyringe';
import { AsyncService } from 'civkit/async-service';
import { ParamValidationError } from 'civkit/civ-rpc';
import { SecurityCompromiseError } from '../shared/lib/errors';
import { isIP } from 'node:net';
import { isIPInNonPublicRange } from '../utils/ip';
import { GlobalLogger } from './logger';
import { lookup } from 'node:dns/promises';
import { Threaded } from './threaded';

const normalizeUrl = require('@esm2cjs/normalize-url').default;

@singleton()
export class MiscService extends AsyncService {

    logger = this.globalLogger.child({ service: this.constructor.name });

    constructor(
        protected globalLogger: GlobalLogger,
    ) {
        super(...arguments);
    }

    override async init() {
        await this.dependencyReady();

        this.emit('ready');
    }

    @Threaded()
    async assertNormalizedUrl(input: string) {
        let result: URL;
        try {
            result = new URL(
                normalizeUrl(
                    input,
                    {
                        stripWWW: false,
                        removeTrailingSlash: false,
                        removeSingleSlash: false,
                        sortQueryParameters: false,
                    }
                )
            );
        } catch (err) {
            throw new ParamValidationError({
                message: `${err}`,
                path: 'url'
            });
        }

        if (!['http:', 'https:', 'blob:'].includes(result.protocol)) {
            throw new ParamValidationError({
                message: `Invalid protocol ${result.protocol}`,
                path: 'url'
            });
        }

        const normalizedHostname = result.hostname.startsWith('[') ? result.hostname.slice(1, -1) : result.hostname;
        let ips: string[] = [];
        const isIp = isIP(normalizedHostname);
        if (isIp) {
            ips.push(normalizedHostname);
        }
        if (
            (result.hostname === 'localhost') ||
            (isIp && isIPInNonPublicRange(normalizedHostname))
        ) {
            this.logger.warn(`Suspicious action: Request to localhost or non-public IP: ${normalizedHostname}`, { href: result.href });
            throw new SecurityCompromiseError({
                message: `Suspicious action: Request to localhost or non-public IP: ${normalizedHostname}`,
                path: 'url'
            });
        }
        if (!isIp && result.protocol !== 'blob:') {
            const resolved = await lookup(result.hostname, { all: true }).catch((err) => {
                if (err.code === 'ENOTFOUND') {
                    return Promise.reject(new ParamValidationError({
                        message: `Domain '${result.hostname}' could not be resolved`,
                        path: 'url'
                    }));
                }

                return;
            });
            if (resolved) {
                for (const x of resolved) {
                    if (isIPInNonPublicRange(x.address)) {
                        this.logger.warn(`Suspicious action: Domain resolved to non-public IP: ${result.hostname} => ${x.address}`, { href: result.href, ip: x.address });
                        throw new SecurityCompromiseError({
                            message: `Suspicious action: Domain resolved to non-public IP: ${x.address}`,
                            path: 'url'
                        });
                    }
                    ips.push(x.address);
                }

            }
        }

        return {
            url: result,
            ips
        };
    }

}