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
8 changes: 5 additions & 3 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
#Stage 1: Build
FROM node:lts-alpine AS build
WORKDIR /build
COPY package*.json .
RUN npm install --force
COPY package*.json ./
RUN npm ci --force
COPY . .
RUN npm run deploy:community
RUN node ./meta/dbip-free-sync.js
RUN npm prune --omit=dev --force

#Stage 2: Runtime
FROM node:lts-alpine AS run
LABEL maintainer="swetrix.com <contact@swetrix.com>"

ENV TZ=UTC \
NODE_ENV=production
NODE_ENV=production \
NPM_CONFIG_OMIT=dev

ENV REDIS_HOST=localhost \
REDIS_PORT=6379 \
Expand Down
4 changes: 2 additions & 2 deletions backend/apps/cloud/src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import dayjsTimezone from 'dayjs/plugin/timezone'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import ipRangeCheck from 'ip-range-check'
import {
Injectable,
BadRequestException,
Expand Down Expand Up @@ -71,6 +70,7 @@ import {
sumArrays,
formatDuration,
} from '../common/utils'
import { isIpInRange } from '../common/ip-range'
import { PageviewsDto } from './dto/pageviews.dto'
import { EventsDto } from './dto/events.dto'
import { ProjectService } from '../project/project.service'
Expand Down Expand Up @@ -601,7 +601,7 @@ export class AnalyticsService {
// TODO: Properly validate the ipBlacklist on project update
const ipBlacklist = _filter(project.ipBlacklist, Boolean) as string[]

if (!_isEmpty(ipBlacklist) && ipRangeCheck(ip, ipBlacklist)) {
if (!_isEmpty(ipBlacklist) && isIpInRange(ip, ipBlacklist)) {
throw new BadRequestException(
'Incoming analytics is disabled for this IP address',
)
Expand Down
167 changes: 167 additions & 0 deletions backend/apps/cloud/src/common/ip-range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { isIP } from 'node:net'

interface ParsedIp {
version: 4 | 6
bytes: number[]
}

interface ParseIpOptions {
preserveIPv4Mapped?: boolean
}

const parseIPv4 = (address: string): number[] | null => {
const parts = address.split('.')
if (parts.length !== 4) return null

const bytes = parts.map((part) => {
if (!/^\d+$/.test(part)) return NaN
const value = Number(part)
return value >= 0 && value <= 255 ? value : NaN
})

return bytes.some(Number.isNaN) ? null : bytes
}

const parseHextet = (part: string): number | null => {
if (!/^[0-9a-f]{1,4}$/i.test(part)) return null
return parseInt(part, 16)
}

const parseIPv6Bytes = (address: string): number[] | null => {
let normalised = address.toLowerCase()
const ipv4Tail = normalised.match(/(.+:)(\d+\.\d+\.\d+\.\d+)$/)

if (ipv4Tail) {
const ipv4Bytes = parseIPv4(ipv4Tail[2])
if (!ipv4Bytes) return null

normalised =
ipv4Tail[1] +
[ipv4Bytes[0] * 256 + ipv4Bytes[1], ipv4Bytes[2] * 256 + ipv4Bytes[3]]
.map((part) => part.toString(16))
.join(':')
}

const doubleColonParts = normalised.split('::')
if (doubleColonParts.length > 2) return null

const left = doubleColonParts[0]
? doubleColonParts[0].split(':').filter(Boolean)
: []
const right = doubleColonParts[1]
? doubleColonParts[1].split(':').filter(Boolean)
: []

const missing = 8 - left.length - right.length
if (doubleColonParts.length === 1 && missing !== 0) return null
if (doubleColonParts.length === 2 && missing < 1) return null

const hextets = [...left, ...Array(Math.max(missing, 0)).fill('0'), ...right]

if (hextets.length !== 8) return null

const bytes: number[] = []
for (const hextet of hextets) {
const value = parseHextet(hextet)
if (value === null) return null
bytes.push((value >> 8) & 0xff, value & 0xff)
}

return bytes
}

const isIPv4MappedIPv6 = (bytes: number[]): boolean => {
return (
bytes.length === 16 &&
bytes.slice(0, 10).every((byte) => byte === 0) &&
bytes[10] === 0xff &&
bytes[11] === 0xff
)
}

const parseIp = (
address: string,
options: ParseIpOptions = {},
): ParsedIp | null => {
const version = isIP(address)

if (version === 4) {
const bytes = parseIPv4(address)
return bytes ? { version: 4, bytes } : null
}

if (version === 6) {
const bytes = parseIPv6Bytes(address)
if (!bytes) return null
if (isIPv4MappedIPv6(bytes) && !options.preserveIPv4Mapped) {
return { version: 4, bytes: bytes.slice(12) }
}
return { version: 6, bytes }
}

return null
}

const matchesPrefix = (
target: ParsedIp,
range: ParsedIp,
prefix: number,
): boolean => {
if (target.version !== range.version) return false

const maxPrefix = target.bytes.length * 8
if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxPrefix) {
return false
}

const fullBytes = Math.floor(prefix / 8)
for (let i = 0; i < fullBytes; i += 1) {
if (target.bytes[i] !== range.bytes[i]) return false
}

const remainingBits = prefix % 8
if (remainingBits === 0) return true

const mask = (0xff << (8 - remainingBits)) & 0xff
return (target.bytes[fullBytes] & mask) === (range.bytes[fullBytes] & mask)
}

const matchesSingleRange = (address: string, range: string): boolean => {
const target = parseIp(address)
if (!target) return false

const rangeParts = range.split('/')
if (rangeParts.length > 2) return false

const [rangeAddress, prefixText] = rangeParts
const parsedRange = parseIp(rangeAddress)
if (!parsedRange) return false

if (prefixText === undefined) {
return (
target.version === parsedRange.version &&
target.bytes.length === parsedRange.bytes.length &&
target.bytes.every((byte, index) => byte === parsedRange.bytes[index])
)
}

if (!/^\d+$/.test(prefixText)) return false
if (matchesPrefix(target, parsedRange, Number(prefixText))) return true

const targetAsIPv6 = parseIp(address, { preserveIPv4Mapped: true })
const rangeAsIPv6 = parseIp(rangeAddress, { preserveIPv4Mapped: true })

return !!(
targetAsIPv6 &&
rangeAsIPv6 &&
matchesPrefix(targetAsIPv6, rangeAsIPv6, Number(prefixText))
)
}

export const isIpInRange = (
address: string,
ranges: string | string[],
): boolean => {
const list = Array.isArray(ranges) ? ranges : [ranges]
return list.some((range) => matchesSingleRange(address, range))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as http from 'http'
import * as https from 'https'
import { useAgent } from 'request-filtering-agent'

interface PostOptions {
url: string
headers: Record<string, string>
body: string
timeoutMs: number
}

interface PostResponse {
ok: boolean
status: number
}

export const postWithFilteredAgent = ({
url,
headers,
body,
timeoutMs,
}: PostOptions): Promise<PostResponse> => {
return new Promise((resolve, reject) => {
const target = new URL(url)
const transport = target.protocol === 'https:' ? https : http

let settled = false
let req: http.ClientRequest

const finish = (handler: () => void) => {
if (settled) return
settled = true
handler()
}

req = transport.request(
target,
{
method: 'POST',
headers: {
...headers,
'Content-Length': Buffer.byteLength(body).toString(),
},
agent: useAgent(url),
},
(res) => {
const status = res.statusCode ?? 0

if (status >= 300 && status < 400 && res.headers.location) {
res.resume()
finish(() => reject(new Error('Redirects are not allowed')))
return
}

res.resume()
res.on('end', () =>
finish(() => resolve({ status, ok: status >= 200 && status < 300 })),
)
res.on('error', (reason) => finish(() => reject(reason)))
},
)

req.on('error', (reason) => finish(() => reject(reason)))
req.setTimeout(timeoutMs, () => {
finish(() => {
req.destroy()
reject(new Error(`Request timed out after ${timeoutMs}ms`))
})
})

req.write(body)
req.end()
})
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { Injectable, Logger } from '@nestjs/common'
import { createHmac } from 'crypto'
// using node-fetch instead of undici because the native fetch does not support
// 'agent' option (to prevent SSRF)
import fetch from 'node-fetch'
import { useAgent } from 'request-filtering-agent'
import {
NotificationChannel,
NotificationChannelType,
} from '../entity/notification-channel.entity'
import { ChannelDispatcher, RenderedAlertMessage } from './types'
import { postWithFilteredAgent } from './http-client'

@Injectable()
export class WebhookChannelService implements ChannelDispatcher {
Expand Down Expand Up @@ -64,13 +61,11 @@ export class WebhookChannelService implements ChannelDispatcher {
headers['X-Swetrix-Signature'] = `sha256=${sig}`
}
const webhookUrl = cfg.url
const res = await fetch(webhookUrl, {
method: 'POST',
const res = await postWithFilteredAgent({
url: webhookUrl,
headers,
body: bodyStr,
agent: useAgent(webhookUrl),
signal: AbortSignal.timeout(10_000),
redirect: 'error',
timeoutMs: 10_000,
})
if (!res.ok) {
this.logger.warn(
Expand All @@ -97,13 +92,11 @@ export class WebhookChannelService implements ChannelDispatcher {
const sig = createHmac('sha256', secret).update(payload).digest('hex')
headers['X-Swetrix-Signature'] = `sha256=${sig}`
}
const res = await fetch(url, {
method: 'POST',
const res = await postWithFilteredAgent({
url,
headers,
body: payload,
agent: useAgent(url),
signal: AbortSignal.timeout(5_000),
redirect: 'error',
timeoutMs: 5_000,
})
return res.ok
} catch (reason) {
Expand Down
4 changes: 2 additions & 2 deletions backend/apps/community/src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import dayjsTimezone from 'dayjs/plugin/timezone'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import ipRangeCheck from 'ip-range-check'
import {
Injectable,
BadRequestException,
Expand Down Expand Up @@ -65,6 +64,7 @@ import {
millisecondsToSeconds,
sumArrays,
} from '../common/utils'
import { isIpInRange } from '../common/ip-range'
import { PageviewsDto } from './dto/pageviews.dto'
import { EventsDto } from './dto/events.dto'
import { ProjectService } from '../project/project.service'
Expand Down Expand Up @@ -576,7 +576,7 @@ export class AnalyticsService {
// TODO: Properly validate the ipBlacklist on project update
const ipBlacklist = _filter(project.ipBlacklist, Boolean) as string[]

if (!_isEmpty(ipBlacklist) && ipRangeCheck(ip, ipBlacklist)) {
if (!_isEmpty(ipBlacklist) && isIpInRange(ip, ipBlacklist)) {
throw new BadRequestException(
'Incoming analytics is disabled for this IP address',
)
Expand Down
Loading
Loading