-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathopenapi-route-security.ts
More file actions
129 lines (108 loc) · 4.96 KB
/
Copy pathopenapi-route-security.ts
File metadata and controls
129 lines (108 loc) · 4.96 KB
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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import path from 'path'
const OPENAPI_ALLOWED_METHODS = ['get', 'post', 'delete', 'put', 'patch'] as const
export type OpenApiMethod = (typeof OPENAPI_ALLOWED_METHODS)[number]
const OPENAPI_PATH_ITEM_METADATA_FIELDS = new Set(['$ref', 'summary', 'description', 'servers', 'parameters'])
export interface OpenApiLikeSpec {
paths?: Record<string, unknown>
}
export interface OpenApiRouteDescriptor {
method: OpenApiMethod
openApiPath: string
fastifyPath: string
moduleKey: string
}
const OPENAPI_ROUTE_MANIFEST: ReadonlyArray<OpenApiRouteDescriptor> = [
{ method: 'post', openApiPath: '/reservations', fastifyPath: '/reservations', moduleKey: 'reservations-post' },
{ method: 'delete', openApiPath: '/reservations/{id}', fastifyPath: '/reservations/:id', moduleKey: 'reservations-id-delete' },
{ method: 'get', openApiPath: '/inventory/{sku}', fastifyPath: '/inventory/:sku', moduleKey: 'inventory-sku-get' },
]
const routeManifestBySpecKey = new Map(
OPENAPI_ROUTE_MANIFEST.map((route) => [openApiRouteSpecKey(route.openApiPath, route.method), route] as const),
)
const routeModuleKeyPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
export class OpenApiRouteRegistrationError extends Error {
constructor(message: string) {
super(message)
this.name = 'OpenApiRouteRegistrationError'
}
}
function toPlainObject(value: unknown): Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function openApiRouteSpecKey(openApiPath: string, method: OpenApiMethod): string {
return `${method} ${openApiPath}`
}
function isAllowedOpenApiMethod(method: string): method is OpenApiMethod {
return (OPENAPI_ALLOWED_METHODS as readonly string[]).includes(method)
}
function isOpenApiPathItemMetadataField(field: string): boolean {
return OPENAPI_PATH_ITEM_METADATA_FIELDS.has(field)
}
function isSafeOpenApiPath(openApiPath: string): boolean {
if (!openApiPath.startsWith('/') || openApiPath.includes('\\') || openApiPath.includes('\0')) {
return false
}
const segments = openApiPath.split('/').slice(1)
if (segments.length === 0) return false
return segments.every((segment) => {
if (!segment || segment === '.' || segment === '..') return false
return /^[A-Za-z0-9_-]+$/.test(segment) || /^\{[A-Za-z][A-Za-z0-9_]*\}$/.test(segment)
})
}
export function resolveOpenApiRouteDescriptor(openApiPath: string, rawMethod: string): OpenApiRouteDescriptor {
if (!isAllowedOpenApiMethod(rawMethod)) {
throw new OpenApiRouteRegistrationError(`Unsupported OpenAPI method for route registration: ${rawMethod}`)
}
if (!isSafeOpenApiPath(openApiPath)) {
throw new OpenApiRouteRegistrationError(`Unsafe OpenAPI path for route registration: ${openApiPath}`)
}
const descriptor = routeManifestBySpecKey.get(openApiRouteSpecKey(openApiPath, rawMethod))
if (!descriptor) {
throw new OpenApiRouteRegistrationError(`Unexpected OpenAPI route: ${rawMethod.toUpperCase()} ${openApiPath}`)
}
assertSafeRouteModuleKey(descriptor.moduleKey)
return descriptor
}
export function collectOpenApiRouteDescriptors(spec: OpenApiLikeSpec): OpenApiRouteDescriptor[] {
const descriptors: OpenApiRouteDescriptor[] = []
const seenRoutes = new Set<string>()
const seenModuleKeys = new Set<string>()
for (const [openApiPath, methods] of Object.entries(spec.paths ?? {})) {
const methodMap = toPlainObject(methods)
for (const [rawMethod] of Object.entries(methodMap)) {
if (isOpenApiPathItemMetadataField(rawMethod)) continue
const descriptor = resolveOpenApiRouteDescriptor(openApiPath, rawMethod)
const routeKey = `${descriptor.method} ${descriptor.fastifyPath}`
if (seenRoutes.has(routeKey)) {
throw new OpenApiRouteRegistrationError(`Duplicate OpenAPI route registration: ${routeKey}`)
}
if (seenModuleKeys.has(descriptor.moduleKey)) {
throw new OpenApiRouteRegistrationError(`Duplicate OpenAPI route module registration: ${descriptor.moduleKey}`)
}
seenRoutes.add(routeKey)
seenModuleKeys.add(descriptor.moduleKey)
descriptors.push(descriptor)
}
}
return descriptors
}
function assertSafeRouteModuleKey(key: string): void {
if (!routeModuleKeyPattern.test(key)) {
throw new OpenApiRouteRegistrationError(`Unsafe route module key: ${key}`)
}
}
function isPathWithin(baseDir: string, candidatePath: string): boolean {
const relative = path.relative(baseDir, candidatePath)
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
}
export function resolveRouteModulePath(key: string, extension: 'js' | 'ts', baseRoutesDir: string): string {
assertSafeRouteModuleKey(key)
const resolvedBase = path.resolve(baseRoutesDir)
const candidate = path.resolve(resolvedBase, `${key}.${extension}`)
if (!isPathWithin(resolvedBase, candidate)) {
throw new OpenApiRouteRegistrationError(`Route module path escaped routes directory: ${key}.${extension}`)
}
return candidate
}