Protect sync pull with approved devices

This commit is contained in:
2026-05-08 18:20:01 -04:00
parent b4b862517a
commit 3544f644b1
4 changed files with 444 additions and 1 deletions
+201
View File
@@ -0,0 +1,201 @@
import type { AuthContext } from "./auth.js";
import type { Env } from "./bindings.js";
const DEFAULT_SYNC_PULL_LIMIT = 100;
const MAX_SYNC_PULL_LIMIT = 500;
const SYNC_OBJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/;
const SYNC_OBJECT_TYPE_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,127}$/;
const SHA256_HEX = /^[a-f0-9]{64}$/;
const SYNC_CHANGE_LOG_QUERY = `
SELECT
change_id,
object_id,
object_type,
operation,
payload_hash,
logical_clock,
device_id,
created_at
FROM sync_change_log
WHERE user_id = ? AND change_id > ?
ORDER BY change_id ASC
LIMIT ?
`;
export interface SyncPullDocument {
version: 1;
user_id: string;
device_id: string;
cursor: number;
next_cursor: number;
has_more: boolean;
changes: SyncChangeDocument[];
}
export interface SyncChangeDocument {
change_id: number;
object_id: string;
object_type: string;
operation: "upsert" | "delete";
payload_hash: string;
logical_clock: number;
device_id: string;
created_at: number;
}
interface SyncChangeRow {
change_id: unknown;
object_id: unknown;
object_type: unknown;
operation: unknown;
payload_hash: unknown;
logical_clock: unknown;
device_id: unknown;
created_at: unknown;
}
interface SyncPullQuery {
cursor: number;
limit: number;
}
export class SyncSchemaError extends Error {
constructor(message: string) {
super(message);
this.name = "SyncSchemaError";
}
}
export class SyncRequestError extends Error {
constructor(message: string) {
super(message);
this.name = "SyncRequestError";
}
}
export async function syncPullDocument(
url: URL,
env: Env,
context: AuthContext,
): Promise<SyncPullDocument> {
const deviceId = currentDeviceId(context);
const query = syncPullQuery(url);
const result = await env.ELY_DB.prepare(SYNC_CHANGE_LOG_QUERY)
.bind(context.userId, query.cursor, query.limit + 1)
.all<SyncChangeRow>();
const rows = result.results.slice(0, query.limit);
const changes = rows.map(syncChangeDocument);
return {
version: 1,
user_id: context.userId,
device_id: deviceId,
cursor: query.cursor,
next_cursor: changes.at(-1)?.change_id ?? query.cursor,
has_more: result.results.length > query.limit,
changes,
};
}
function syncPullQuery(url: URL): SyncPullQuery {
assertOnlyQueryParams(url, ["cursor", "limit"]);
const cursor = requiredQueryInteger(url, "cursor", 0, Number.MAX_SAFE_INTEGER);
const limit = optionalQueryInteger(url, "limit", 1, MAX_SYNC_PULL_LIMIT) ?? DEFAULT_SYNC_PULL_LIMIT;
return { cursor, limit };
}
function syncChangeDocument(row: SyncChangeRow): SyncChangeDocument {
return {
change_id: integerValue(row.change_id, "change_id", 0, Number.MAX_SAFE_INTEGER),
object_id: objectId(row.object_id),
object_type: objectType(row.object_type),
operation: operation(row.operation),
payload_hash: payloadHash(row.payload_hash),
logical_clock: integerValue(row.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER),
device_id: objectId(row.device_id),
created_at: integerValue(row.created_at, "created_at", 0, Number.MAX_SAFE_INTEGER),
};
}
function currentDeviceId(context: AuthContext): string {
if (context.deviceId === undefined) {
throw new SyncSchemaError("device_context_required");
}
return context.deviceId;
}
function assertOnlyQueryParams(url: URL, fields: string[]): void {
const allowed = new Set(fields);
for (const field of url.searchParams.keys()) {
if (!allowed.has(field)) {
throw new SyncRequestError(`unexpected_query:${field}`);
}
}
}
function requiredQueryInteger(url: URL, field: string, min: number, max: number): number {
const value = url.searchParams.get(field);
if (value === null) {
throw new SyncRequestError(`${field}_required`);
}
return queryInteger(value, field, min, max);
}
function optionalQueryInteger(
url: URL,
field: string,
min: number,
max: number,
): number | undefined {
const value = url.searchParams.get(field);
if (value === null) {
return undefined;
}
return queryInteger(value, field, min, max);
}
function queryInteger(value: string, field: string, min: number, max: number): number {
if (!/^[0-9]+$/.test(value)) {
throw new SyncRequestError(`${field}_invalid`);
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
throw new SyncRequestError(`${field}_invalid`);
}
return parsed;
}
function integerValue(value: unknown, field: string, min: number, max: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
throw new SyncSchemaError(`${field}_invalid`);
}
return value;
}
function objectId(value: unknown): string {
if (typeof value !== "string" || !SYNC_OBJECT_ID_PATTERN.test(value)) {
throw new SyncSchemaError("object_id_invalid");
}
return value;
}
function objectType(value: unknown): string {
if (typeof value !== "string" || !SYNC_OBJECT_TYPE_PATTERN.test(value)) {
throw new SyncSchemaError("object_type_invalid");
}
return value;
}
function operation(value: unknown): SyncChangeDocument["operation"] {
if (value !== "upsert" && value !== "delete") {
throw new SyncSchemaError("operation_invalid");
}
return value;
}
function payloadHash(value: unknown): string {
if (typeof value !== "string" || !SHA256_HEX.test(value)) {
throw new SyncSchemaError("payload_hash_invalid");
}
return value;
}