mirror of
https://github.com/germondai/trawl.git
synced 2026-08-17 12:11:23 +02:00
fix(api): validate request bodies before scraping
This commit is contained in:
@@ -4,7 +4,7 @@ import type { ScrapeRequest } from "@trawl/types"
|
||||
import { Elysia } from "elysia"
|
||||
import { flareSolverrError } from "../adapters/flaresolverr"
|
||||
import { getDeps, getPool } from "../deps"
|
||||
import { validateScrapeRequest } from "../validation"
|
||||
import { requestUrl, validateScrapeRequest } from "../validation"
|
||||
|
||||
// Native TRAWL API — richer response (tier, timings, sessionCached).
|
||||
// Error mapping:
|
||||
@@ -13,13 +13,13 @@ import { validateScrapeRequest } from "../validation"
|
||||
// 500 — other scrape exception (native { error })
|
||||
export function scrapeRoute() {
|
||||
return new Elysia().post("/scrape", async ({ body, set }) => {
|
||||
if (!getPool()) {
|
||||
set.status = 503
|
||||
return { error: "Browser pool initializing, retry in a few seconds" }
|
||||
}
|
||||
const req = body as ScrapeRequest
|
||||
try {
|
||||
validateScrapeRequest(req)
|
||||
validateScrapeRequest(body)
|
||||
const req: ScrapeRequest = body
|
||||
if (!getPool()) {
|
||||
set.status = 503
|
||||
return { error: "Browser pool initializing, retry in a few seconds" }
|
||||
}
|
||||
return await scrape({ ...req, headers: sanitizeHeaders(req.headers) }, getDeps())
|
||||
} catch (err) {
|
||||
if (err instanceof RequestValidationError) {
|
||||
@@ -28,7 +28,7 @@ export function scrapeRoute() {
|
||||
}
|
||||
if (err instanceof PoolExhaustedError) {
|
||||
set.status = 429
|
||||
return flareSolverrError(req.url ?? "", "Browser pool saturated, retry shortly")
|
||||
return flareSolverrError(requestUrl(body), "Browser pool saturated, retry shortly")
|
||||
}
|
||||
set.status = 500
|
||||
if (err instanceof ScrapeError) {
|
||||
|
||||
+17
-14
@@ -4,25 +4,28 @@ import type { FlareSolverrRequest, FlareSolverrResponse } from "@trawl/types"
|
||||
import { Elysia } from "elysia"
|
||||
import { buildScrapeRequestFromFlareSolverr, flareSolverrError } from "../adapters/flaresolverr"
|
||||
import { getDeps, getPool } from "../deps"
|
||||
import { requestUrl, validateFlareSolverrRequest } from "../validation"
|
||||
|
||||
// FlareSolverr v2 compat — always open (the v2 spec has no auth header)
|
||||
export function v1Route() {
|
||||
return new Elysia().post("/v1", async ({ body, set }) => {
|
||||
const req = body as FlareSolverrRequest
|
||||
const startTimestamp = Date.now()
|
||||
const cmd = req.cmd ?? "request.get"
|
||||
|
||||
if (cmd !== "request.get" && cmd !== "request.post") {
|
||||
set.status = 400
|
||||
return flareSolverrError(req.url, `Unknown cmd: ${cmd}`)
|
||||
}
|
||||
|
||||
if (!getPool()) {
|
||||
set.status = 503
|
||||
return flareSolverrError(req.url, "Browser pool initializing, retry in a few seconds")
|
||||
}
|
||||
|
||||
try {
|
||||
validateFlareSolverrRequest(body)
|
||||
const req: FlareSolverrRequest = body
|
||||
const cmd = req.cmd ?? "request.get"
|
||||
|
||||
if (cmd !== "request.get" && cmd !== "request.post") {
|
||||
set.status = 400
|
||||
return flareSolverrError(req.url, `Unknown cmd: ${cmd}`)
|
||||
}
|
||||
|
||||
if (!getPool()) {
|
||||
set.status = 503
|
||||
return flareSolverrError(req.url, "Browser pool initializing, retry in a few seconds")
|
||||
}
|
||||
|
||||
const scrapeRequest = buildScrapeRequestFromFlareSolverr(req)
|
||||
const result = await scrape(scrapeRequest, getDeps())
|
||||
return {
|
||||
@@ -43,10 +46,10 @@ export function v1Route() {
|
||||
} catch (err) {
|
||||
if (err instanceof RequestValidationError) {
|
||||
set.status = err.statusCode
|
||||
return flareSolverrError(req.url, err.message)
|
||||
return flareSolverrError(requestUrl(body), err.message)
|
||||
}
|
||||
set.status = err instanceof PoolExhaustedError ? 429 : 500
|
||||
return flareSolverrError(req.url, err instanceof Error ? err.message : String(err))
|
||||
return flareSolverrError(requestUrl(body), err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RequestValidationError } from "@trawl/tiers"
|
||||
import { requestUrl, validateFlareSolverrRequest, validateScrapeRequest } from "./validation"
|
||||
|
||||
const invalidBodies: unknown[] = [undefined, null, [], "text", 42, true]
|
||||
|
||||
describe("API request validation", () => {
|
||||
for (const body of invalidBodies) {
|
||||
test(`rejects non-object body ${JSON.stringify(body)}`, () => {
|
||||
expect(() => validateFlareSolverrRequest(body)).toThrow(
|
||||
new RequestValidationError("Request body must be a JSON object", 400),
|
||||
)
|
||||
expect(() => validateScrapeRequest(body)).toThrow(
|
||||
new RequestValidationError("Request body must be a JSON object", 400),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
for (const url of [undefined, null, "", " ", 42]) {
|
||||
test(`rejects invalid url ${JSON.stringify(url)}`, () => {
|
||||
expect(() => validateFlareSolverrRequest({ url })).toThrow(
|
||||
new RequestValidationError("url must be a non-empty string", 400),
|
||||
)
|
||||
expect(() => validateScrapeRequest({ url })).toThrow(
|
||||
new RequestValidationError("url must be a non-empty string", 400),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
test("accepts valid request bodies for both API shapes", () => {
|
||||
expect(() => validateFlareSolverrRequest({ cmd: "request.get", url: "https://example.com" })).not.toThrow()
|
||||
expect(() => validateScrapeRequest({ method: "GET", url: "https://example.com" })).not.toThrow()
|
||||
})
|
||||
|
||||
test("extracts only string URLs for error envelopes", () => {
|
||||
expect(requestUrl({ url: "https://example.com" })).toBe("https://example.com")
|
||||
expect(requestUrl({ url: 42 })).toBe("")
|
||||
expect(requestUrl(null)).toBe("")
|
||||
})
|
||||
})
|
||||
@@ -5,9 +5,37 @@ import {
|
||||
SUPPORTED_METHODS,
|
||||
sanitizeHeaders,
|
||||
} from "@trawl/tiers"
|
||||
import type { ScrapeRequest } from "@trawl/types"
|
||||
import type { FlareSolverrRequest, ScrapeRequest } from "@trawl/types"
|
||||
|
||||
export function validateScrapeRequest(req: ScrapeRequest): void {
|
||||
type RequestRecord = Record<string, unknown>
|
||||
|
||||
function requireRequestRecord(body: unknown): asserts body is RequestRecord {
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
||||
throw new RequestValidationError("Request body must be a JSON object", 400)
|
||||
}
|
||||
}
|
||||
|
||||
function requireUrl(req: RequestRecord): void {
|
||||
if (typeof req.url !== "string" || req.url.trim().length === 0) {
|
||||
throw new RequestValidationError("url must be a non-empty string", 400)
|
||||
}
|
||||
}
|
||||
|
||||
export function requestUrl(body: unknown): string {
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) return ""
|
||||
const url = (body as RequestRecord).url
|
||||
return typeof url === "string" ? url : ""
|
||||
}
|
||||
|
||||
export function validateFlareSolverrRequest(body: unknown): asserts body is FlareSolverrRequest {
|
||||
requireRequestRecord(body)
|
||||
requireUrl(body)
|
||||
}
|
||||
|
||||
export function validateScrapeRequest(body: unknown): asserts body is ScrapeRequest {
|
||||
requireRequestRecord(body)
|
||||
requireUrl(body)
|
||||
const req = body as RequestRecord & Partial<ScrapeRequest>
|
||||
if (!isValidMethod(req.method)) {
|
||||
throw new RequestValidationError(
|
||||
`Unsupported method: ${String(req.method)} (allowed: ${SUPPORTED_METHODS.join(", ")})`,
|
||||
|
||||
Reference in New Issue
Block a user