feat(server): lint for trailing slashes in sync URL and extra slashes in customRequestHandler

This commit is contained in:
perf3ct 2025-06-17 19:37:40 +00:00
parent 2c87721953
commit 0fe89115d1
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
3 changed files with 103 additions and 10 deletions

View File

@ -5,7 +5,7 @@ import cls from "../services/cls.js";
import sql from "../services/sql.js"; import sql from "../services/sql.js";
import becca from "../becca/becca.js"; import becca from "../becca/becca.js";
import type { Request, Response, Router } from "express"; import type { Request, Response, Router } from "express";
import { safeExtractMessageAndStackFromError } from "../services/utils.js"; import { safeExtractMessageAndStackFromError, normalizeCustomHandlerPattern } from "../services/utils.js";
function handleRequest(req: Request, res: Response) { function handleRequest(req: Request, res: Response) {
@ -38,15 +38,23 @@ function handleRequest(req: Request, res: Response) {
continue; continue;
} }
const regex = new RegExp(`^${attr.value}$`); // Get normalized patterns to handle both trailing slash cases
let match; const patterns = normalizeCustomHandlerPattern(attr.value);
let match = null;
try { // Try each pattern until we find a match
match = path.match(regex); for (const pattern of patterns) {
} catch (e: unknown) { try {
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e); const regex = new RegExp(`^${pattern}$`);
log.error(`Testing path for label '${attr.attributeId}', regex '${attr.value}' failed with error: ${errMessage}, stack: ${errStack}`); match = path.match(regex);
continue; if (match) {
break; // Found a match, exit pattern loop
}
} catch (e: unknown) {
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);
log.error(`Testing path for label '${attr.attributeId}', regex '${pattern}' failed with error: ${errMessage}, stack: ${errStack}`);
continue;
}
} }
if (!match) { if (!match) {

View File

@ -2,6 +2,7 @@
import optionService from "./options.js"; import optionService from "./options.js";
import config from "./config.js"; import config from "./config.js";
import { normalizeUrl } from "./utils.js";
/* /*
* Primary configuration for sync is in the options (document), but we allow to override * Primary configuration for sync is in the options (document), but we allow to override
@ -17,7 +18,10 @@ function get(name: keyof typeof config.Sync) {
export default { export default {
// env variable is the easiest way to guarantee we won't overwrite prod data during development // env variable is the easiest way to guarantee we won't overwrite prod data during development
// after copying prod document/data directory // after copying prod document/data directory
getSyncServerHost: () => get("syncServerHost"), getSyncServerHost: () => {
const host = get("syncServerHost");
return host ? normalizeUrl(host) : host;
},
isSyncSetup: () => { isSyncSetup: () => {
const syncServerHost = get("syncServerHost"); const syncServerHost = get("syncServerHost");

View File

@ -375,6 +375,85 @@ export function safeExtractMessageAndStackFromError(err: unknown): [errMessage:
return (err instanceof Error) ? [err.message, err.stack] as const : ["Unknown Error", undefined] as const; return (err instanceof Error) ? [err.message, err.stack] as const : ["Unknown Error", undefined] as const;
} }
/**
* Normalizes URL by removing trailing slashes and fixing double slashes.
* Preserves the protocol (http://, https://) but removes trailing slashes from the rest.
*
* @param url The URL to normalize
* @returns The normalized URL without trailing slashes
*/
export function normalizeUrl(url: string): string {
if (!url || typeof url !== 'string') {
return url;
}
// Trim whitespace
url = url.trim();
if (!url) {
return url;
}
// Remove trailing slash, but preserve protocol
if (url.endsWith('/') && !url.match(/^https?:\/\/$/)) {
url = url.slice(0, -1);
}
// Fix double slashes (except in protocol)
url = url.replace(/([^:]\/)\/+/g, '$1');
return url;
}
/**
* Normalizes a path pattern for custom request handlers.
* Ensures both trailing slash and non-trailing slash versions are handled.
*
* @param pattern The original pattern from customRequestHandler attribute
* @returns An array of patterns to match both with and without trailing slash
*/
export function normalizeCustomHandlerPattern(pattern: string): string[] {
if (!pattern || typeof pattern !== 'string') {
return [pattern];
}
pattern = pattern.trim();
if (!pattern) {
return [pattern];
}
// If pattern already ends with optional trailing slash, return as-is
if (pattern.endsWith('/?$') || pattern.endsWith('/?)')) {
return [pattern];
}
// If pattern ends with $, handle it specially
if (pattern.endsWith('$')) {
const basePattern = pattern.slice(0, -1);
// If already ends with slash, create both versions
if (basePattern.endsWith('/')) {
const withoutSlash = basePattern.slice(0, -1) + '$';
const withSlash = pattern;
return [withoutSlash, withSlash];
} else {
// Add optional trailing slash
const withSlash = basePattern + '/?$';
return [withSlash];
}
}
// For patterns without $, add both versions
if (pattern.endsWith('/')) {
const withoutSlash = pattern.slice(0, -1);
return [withoutSlash, pattern];
} else {
const withSlash = pattern + '/';
return [pattern, withSlash];
}
}
export default { export default {
compareVersions, compareVersions,
@ -400,6 +479,8 @@ export default {
md5, md5,
newEntityId, newEntityId,
normalize, normalize,
normalizeCustomHandlerPattern,
normalizeUrl,
quoteRegex, quoteRegex,
randomSecureToken, randomSecureToken,
randomString, randomString,