fix(coding): remediate data service review findings

This commit is contained in:
2026-08-27 00:49:59 +08:00
parent 239e20d2cf
commit 38843e091f
15 changed files with 449 additions and 194 deletions

View File

@@ -0,0 +1,177 @@
import type { IncomingMessage } from 'node:http';
export const MAX_CURSOR_LENGTH = 1_024;
export const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/;
export const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
export type ParserFailure = (
status: number,
code: string,
message: string,
) => never;
export type ParserMessages = Readonly<{
invalidRequest: string;
invalidRevision: string;
requestTooLarge: string;
}>;
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function boundedString(value: unknown, maximum: number): string | null {
if (typeof value !== 'string') return null;
return value && value.length <= maximum ? value : null;
}
export async function readBoundedJson(
req: IncomingMessage,
maxBytes: number,
fail: ParserFailure,
messages: ParserMessages,
): Promise<Record<string, unknown>> {
const declared = req.headers['content-length'];
const declaredValue = Array.isArray(declared) ? declared[0] : declared;
if (declaredValue && /^\d+$/.test(declaredValue) && Number(declaredValue) > maxBytes) {
return fail(413, 'request_too_large', messages.requestTooLarge);
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.byteLength;
if (size > maxBytes) {
return fail(413, 'request_too_large', messages.requestTooLarge);
}
chunks.push(buffer);
}
try {
const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
if (!isRecord(value)) throw new Error('object required');
return value;
} catch {
return fail(422, 'invalid_request', messages.invalidRequest);
}
}
export function requireExactKeys(
value: Record<string, unknown>,
keys: readonly string[],
fail: ParserFailure,
message: string,
): void {
const expected = new Set(keys);
if (Object.keys(value).some((key) => !expected.has(key))
|| keys.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) {
fail(422, 'invalid_request', message);
}
}
function decodeSegment(
value: string,
maximum: number,
pattern: RegExp,
fail: ParserFailure,
message: string,
): string {
let decoded: string;
try {
decoded = decodeURIComponent(value);
} catch {
return fail(422, 'invalid_request', message);
}
if (decoded.length > maximum || !pattern.test(decoded)) {
return fail(422, 'invalid_request', message);
}
return decoded;
}
export function parseCollection(
value: string,
fail: ParserFailure,
message: string,
): string {
return decodeSegment(value, 48, COLLECTION_PATTERN, fail, message);
}
export function parseDocumentId(
value: string,
fail: ParserFailure,
message: string,
): string {
const documentId = decodeSegment(value, 128, DOCUMENT_ID_PATTERN, fail, message);
if (documentId === '.' || documentId === '..') {
return fail(422, 'invalid_request', message);
}
return documentId;
}
export function requireQueryKeys(
url: URL,
allowed: readonly string[],
fail: ParserFailure,
message: string,
): void {
const accepted = new Set(allowed);
if ([...url.searchParams.keys()].some((key) => !accepted.has(key))) {
fail(422, 'invalid_request', message);
}
}
export function parseLimit(
url: URL,
fail: ParserFailure,
message: string,
): number | undefined {
const values = url.searchParams.getAll('limit');
if (values.length === 0) return undefined;
if (values.length !== 1 || !/^[1-9]\d*$/.test(values[0])) {
return fail(422, 'invalid_request', message);
}
const limit = Number(values[0]);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
return fail(422, 'invalid_request', message);
}
return limit;
}
export function parseCursor(
url: URL,
fail: ParserFailure,
message: string,
): string | undefined {
const values = url.searchParams.getAll('cursor');
if (values.length === 0) return undefined;
const cursor = values.length === 1 ? boundedString(values[0], MAX_CURSOR_LENGTH) : null;
if (!cursor) return fail(422, 'invalid_request', message);
return cursor;
}
export function parseIfMatch(
req: IncomingMessage,
fail: ParserFailure,
message: string,
): number | undefined {
const value = req.headers['if-match'];
if (value === undefined) return undefined;
if (Array.isArray(value) || !/^"[1-9]\d*"$/.test(value)) {
return fail(422, 'invalid_revision', message);
}
const revision = Number(value.slice(1, -1));
if (!Number.isSafeInteger(revision) || revision < 1) {
return fail(422, 'invalid_revision', message);
}
return revision;
}
export function parseData(
value: unknown,
fail: ParserFailure,
message: string,
): Record<string, unknown> {
if (!isRecord(value)) {
return fail(422, 'invalid_request', message);
}
return value;
}

View File

@@ -5,12 +5,27 @@ import {
type DataServiceErrorContext,
type DataServiceHostResult,
} from '../../../shared/data-service';
import {
COLLECTION_PATTERN,
parseCollection as parseCollectionSegment,
parseCursor as parseCursorValue,
parseData as parseDataValue,
parseDocumentId as parseDocumentIdValue,
parseIfMatch as parseIfMatchValue,
parseLimit as parseLimitValue,
readBoundedJson as readBoundedJsonBody,
requireExactKeys as requireExactKeysValue,
requireQueryKeys as requireQueryKeysValue,
type ParserMessages,
} from './data-service-parsers';
const LOCAL_ROOT = '/api/works/data-service';
const MAX_REQUEST_BYTES = 98_304;
const MAX_CURSOR_LENGTH = 1_024;
const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/;
const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
const PARSER_MESSAGES: ParserMessages = {
invalidRequest: 'Data Service request is invalid',
invalidRevision: 'Data Service document revision is invalid',
requestTooLarge: 'Data Service request is too large',
};
class DataServiceRouteError extends Error {
constructor(
@@ -23,15 +38,6 @@ class DataServiceRouteError extends Error {
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function boundedString(value: unknown, maximum: number): string | null {
if (typeof value !== 'string') return null;
return value && value.length <= maximum ? value : null;
}
function routeFailure<T>(
status: number,
code: string,
@@ -59,69 +65,28 @@ function methodNotAllowed<T>(): DataServiceHostResult<T> {
return routeFailure(405, 'method_not_allowed', 'Data Service method is not allowed');
}
function parserFailure(status: number, code: string, message: string): never {
throw new DataServiceRouteError(status, code, message);
}
async function readBoundedJson(req: IncomingMessage): Promise<Record<string, unknown>> {
const declared = req.headers['content-length'];
const declaredValue = Array.isArray(declared) ? declared[0] : declared;
if (declaredValue && /^\d+$/.test(declaredValue) && Number(declaredValue) > MAX_REQUEST_BYTES) {
throw new DataServiceRouteError(413, 'request_too_large', 'Data Service request is too large');
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.byteLength;
if (size > MAX_REQUEST_BYTES) {
throw new DataServiceRouteError(413, 'request_too_large', 'Data Service request is too large');
}
chunks.push(buffer);
}
try {
const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
if (!isRecord(value)) throw new Error('object required');
return value;
} catch {
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
}
return readBoundedJsonBody(req, MAX_REQUEST_BYTES, parserFailure, PARSER_MESSAGES);
}
function requireExactKeys(value: Record<string, unknown>, keys: readonly string[]): void {
const expected = new Set(keys);
if (Object.keys(value).some((key) => !expected.has(key))
|| keys.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) {
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
}
}
function decodeSegment(value: string, maximum: number, pattern: RegExp): string {
let decoded: string;
try {
decoded = decodeURIComponent(value);
} catch {
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
}
if (decoded.length > maximum || !pattern.test(decoded)) {
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
}
return decoded;
requireExactKeysValue(value, keys, parserFailure, PARSER_MESSAGES.invalidRequest);
}
function parseCollection(value: string): string {
return decodeSegment(value, 48, COLLECTION_PATTERN);
return parseCollectionSegment(value, parserFailure, PARSER_MESSAGES.invalidRequest);
}
function parseDocumentId(value: string): string {
const documentId = decodeSegment(value, 128, DOCUMENT_ID_PATTERN);
if (documentId === '.' || documentId === '..') {
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
}
return documentId;
return parseDocumentIdValue(value, parserFailure, PARSER_MESSAGES.invalidRequest);
}
function requireQueryKeys(url: URL, allowed: readonly string[]): void {
const accepted = new Set(allowed);
if ([...url.searchParams.keys()].some((key) => !accepted.has(key))) {
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
}
requireQueryKeysValue(url, allowed, parserFailure, PARSER_MESSAGES.invalidRequest);
}
function requireConfirmed(url: URL): void {
@@ -137,37 +102,15 @@ function requireConfirmed(url: URL): void {
}
function parseLimit(url: URL): number | undefined {
const values = url.searchParams.getAll('limit');
if (values.length === 0) return undefined;
if (values.length !== 1 || !/^[1-9]\d*$/.test(values[0])) {
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
}
const limit = Number(values[0]);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
}
return limit;
return parseLimitValue(url, parserFailure, PARSER_MESSAGES.invalidRequest);
}
function parseCursor(url: URL): string | undefined {
const values = url.searchParams.getAll('cursor');
if (values.length === 0) return undefined;
const cursor = values.length === 1 ? boundedString(values[0], MAX_CURSOR_LENGTH) : null;
if (!cursor) throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
return cursor;
return parseCursorValue(url, parserFailure, PARSER_MESSAGES.invalidRequest);
}
function parseIfMatch(req: IncomingMessage): number | undefined {
const value = req.headers['if-match'];
if (value === undefined) return undefined;
if (Array.isArray(value) || !/^"[1-9]\d*"$/.test(value)) {
throw new DataServiceRouteError(422, 'invalid_revision', 'Data Service document revision is invalid');
}
const revision = Number(value.slice(1, -1));
if (!Number.isSafeInteger(revision) || revision < 1) {
throw new DataServiceRouteError(422, 'invalid_revision', 'Data Service document revision is invalid');
}
return revision;
return parseIfMatchValue(req, parserFailure, PARSER_MESSAGES.invalidRevision);
}
function parseCollections(value: unknown): string[] {
@@ -179,10 +122,7 @@ function parseCollections(value: unknown): string[] {
}
function parseData(value: unknown): Record<string, unknown> {
if (!isRecord(value)) {
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
}
return value;
return parseDataValue(value, parserFailure, PARSER_MESSAGES.invalidRequest);
}
function sendResult<T>(res: ServerResponse, result: DataServiceHostResult<T>): void {
@@ -331,5 +271,3 @@ export async function handleDataServiceRoutes(
return true;
}
}
export const handleDataServiceRoute = handleDataServiceRoutes;

View File

@@ -6,12 +6,27 @@ import type {
DataServiceErrorContext,
DataServiceHostResult,
} from '../../../shared/data-service';
import {
isRecord,
parseCollection as parseCollectionSegment,
parseCursor as parseCursorValue,
parseData as parseDataValue,
parseDocumentId as parseDocumentIdValue,
parseIfMatch as parseIfMatchValue,
parseLimit as parseLimitValue,
readBoundedJson as readBoundedJsonBody,
requireExactKeys as requireExactKeysValue,
requireQueryKeys as requireQueryKeysValue,
type ParserMessages,
} from './data-service-parsers';
const MAX_CURSOR_LENGTH = 1_024;
const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/;
const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
const CORS_METHODS = 'GET, PUT, DELETE, OPTIONS';
const CORS_HEADERS = 'Authorization, Content-Type, If-Match';
const PARSER_MESSAGES: ParserMessages = {
invalidRequest: 'Preview data request is invalid',
invalidRevision: 'Data Service document revision is invalid',
requestTooLarge: 'Preview data request is too large',
};
class PreviewDataRouteError extends Error {
constructor(
@@ -25,15 +40,6 @@ class PreviewDataRouteError extends Error {
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function boundedString(value: unknown, maximum: number): string | null {
if (typeof value !== 'string') return null;
return value && value.length <= maximum ? value : null;
}
function routeFailure<T>(
status: number,
code: string,
@@ -144,110 +150,44 @@ function sendResult<T>(res: ServerResponse, result: DataServiceHostResult<T>): v
}
}
function parserFailure(status: number, code: string, message: string): never {
throw new PreviewDataRouteError(status, code, message);
}
async function readBoundedJson(req: IncomingMessage): Promise<Record<string, unknown>> {
const declared = req.headers['content-length'];
const declaredValue = Array.isArray(declared) ? declared[0] : declared;
if (declaredValue && /^\d+$/.test(declaredValue) && Number(declaredValue) > PREVIEW_DATA_MAX_REQUEST_BYTES) {
throw new PreviewDataRouteError(413, 'request_too_large', 'Preview data request is too large');
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.byteLength;
if (size > PREVIEW_DATA_MAX_REQUEST_BYTES) {
throw new PreviewDataRouteError(413, 'request_too_large', 'Preview data request is too large');
}
chunks.push(buffer);
}
try {
const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
if (!isRecord(value)) throw new Error('object required');
return value;
} catch {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
return readBoundedJsonBody(req, PREVIEW_DATA_MAX_REQUEST_BYTES, parserFailure, PARSER_MESSAGES);
}
function requireExactKeys(value: Record<string, unknown>, keys: readonly string[]): void {
const expected = new Set(keys);
if (Object.keys(value).some((key) => !expected.has(key))
|| keys.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
}
function decodeSegment(value: string, maximum: number, pattern: RegExp): string {
let decoded: string;
try {
decoded = decodeURIComponent(value);
} catch {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
if (decoded.length > maximum || !pattern.test(decoded)) {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
return decoded;
requireExactKeysValue(value, keys, parserFailure, PARSER_MESSAGES.invalidRequest);
}
function parseCollection(value: string): string {
return decodeSegment(value, 48, COLLECTION_PATTERN);
return parseCollectionSegment(value, parserFailure, PARSER_MESSAGES.invalidRequest);
}
function parseDocumentId(value: string): string {
const documentId = decodeSegment(value, 128, DOCUMENT_ID_PATTERN);
if (documentId === '.' || documentId === '..') {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
return documentId;
return parseDocumentIdValue(value, parserFailure, PARSER_MESSAGES.invalidRequest);
}
function requireQueryKeys(url: URL, allowed: readonly string[]): void {
const accepted = new Set(allowed);
if ([...url.searchParams.keys()].some((key) => !accepted.has(key))) {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
requireQueryKeysValue(url, allowed, parserFailure, PARSER_MESSAGES.invalidRequest);
}
function parseLimit(url: URL): number | undefined {
const values = url.searchParams.getAll('limit');
if (values.length === 0) return undefined;
if (values.length !== 1 || !/^[1-9]\d*$/.test(values[0])) {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
const limit = Number(values[0]);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
return limit;
return parseLimitValue(url, parserFailure, PARSER_MESSAGES.invalidRequest);
}
function parseCursor(url: URL): string | undefined {
const values = url.searchParams.getAll('cursor');
if (values.length === 0) return undefined;
const cursor = values.length === 1 ? boundedString(values[0], MAX_CURSOR_LENGTH) : null;
if (!cursor) throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
return cursor;
return parseCursorValue(url, parserFailure, PARSER_MESSAGES.invalidRequest);
}
function parseIfMatch(req: IncomingMessage): number | undefined {
const value = req.headers['if-match'];
if (value === undefined) return undefined;
if (Array.isArray(value) || !/^"[1-9]\d*"$/.test(value)) {
throw new PreviewDataRouteError(422, 'invalid_revision', 'Data Service document revision is invalid');
}
const revision = Number(value.slice(1, -1));
if (!Number.isSafeInteger(revision) || revision < 1) {
throw new PreviewDataRouteError(422, 'invalid_revision', 'Data Service document revision is invalid');
}
return revision;
return parseIfMatchValue(req, parserFailure, PARSER_MESSAGES.invalidRevision);
}
function parseData(value: unknown): Record<string, unknown> {
if (!isRecord(value)) {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
return value;
return parseDataValue(value, parserFailure, PARSER_MESSAGES.invalidRequest);
}
function requireJsonContentType(req: IncomingMessage): void {