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
+37
View File
@@ -13,6 +13,12 @@ const RATE_LIMIT_WINDOW_SECONDS = 60;
export type ApiHandler = () => Promise<Response>;
export type AuthenticatedApiHandler = (context: AuthContext) => Promise<Response>;
const APPROVED_DEVICE_QUERY = `
SELECT device_id
FROM user_devices
WHERE user_id = ? AND device_id = ? AND approval_status = 'approved' AND revoked_at IS NULL
`;
export async function withPublicApiControls(
request: Request,
env: Env,
@@ -103,6 +109,37 @@ export async function withAuthenticatedApiControls(
}
}
export async function withApprovedDeviceApiControls(
request: Request,
env: Env,
route: string,
allowedMethods: readonly string[],
handler: AuthenticatedApiHandler,
): Promise<Response> {
return withAuthenticatedApiControls(request, env, route, allowedMethods, async (context) => {
if (context.deviceId === undefined) {
return jsonResponse({ error: "device_context_required" }, 403, {
"Cache-Control": "no-store",
});
}
const row = await env.ELY_DB.prepare(APPROVED_DEVICE_QUERY)
.bind(context.userId, context.deviceId)
.first<ApprovedDeviceRow>();
if (row === null) {
return jsonResponse({ error: "device_not_approved" }, 403, {
"Cache-Control": "no-store",
});
}
return handler(context);
});
}
interface ApprovedDeviceRow {
device_id: unknown;
}
function rateLimitKey(environment: string, route: string): string {
return `${environment}:${route}`;
}
+37 -1
View File
@@ -1,5 +1,9 @@
import type { Env } from "./bindings.js";
import { withAuthenticatedApiControls, withPublicApiControls } from "./api_controls.js";
import {
withApprovedDeviceApiControls,
withAuthenticatedApiControls,
withPublicApiControls,
} from "./api_controls.js";
import {
DevicePermissionError,
DevicePersistenceError,
@@ -31,6 +35,7 @@ import {
parsePublicSigningKeysDocument,
publicSigningKeysKvKey,
} from "./signing_keys.js";
import { SyncRequestError, SyncSchemaError, syncPullDocument } from "./sync_pull.js";
import { jsonResponse } from "./responses.js";
export default {
@@ -169,6 +174,37 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
},
);
}
if (url.pathname === "/api/sync/pull") {
return withApprovedDeviceApiControls(
request,
env,
"sync.pull",
["GET"],
async (context) => {
try {
return jsonResponse(await syncPullDocument(url, env, context), 200, {
"Cache-Control": "no-store",
});
} catch (error) {
if (error instanceof SyncRequestError) {
return jsonResponse(
{ error: "invalid_sync_pull" },
400,
{ "Cache-Control": "no-store" },
);
}
if (error instanceof SyncSchemaError) {
return jsonResponse(
{ error: "sync_pull_invalid" },
500,
{ "Cache-Control": "no-store" },
);
}
throw error;
}
},
);
}
if (url.pathname === "/api/plugins/signing-keys") {
return withPublicApiControls(request, env, "plugins.signing_keys", ["GET"], () =>
handlePublicSigningKeys(env),
+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;
}
+169
View File
@@ -0,0 +1,169 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
import { handleRequest } from "../src/index.js";
import { ACCESS_TOKEN, sessionDocument, testD1Database, testEnv } from "./devices_test_support.js";
const PAYLOAD_HASH = "a".repeat(64);
describe("sync pull routes", () => {
it("returns sync change log entries for an approved current device", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [{ device_id: "device-01" }],
allRows: [
syncChangeRow({ change_id: 11, object_id: "tab-01" }),
syncChangeRow({ change_id: 12, object_id: "bookmark-01", object_type: "bookmarks" }),
],
});
const response = await handleRequest(
new Request("https://elydora.test/api/sync/pull?cursor=10&limit=2", {
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
}),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
}),
);
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.deepEqual(await response.json(), {
version: 1,
user_id: "user-01",
device_id: "device-01",
cursor: 10,
next_cursor: 12,
has_more: false,
changes: [
syncChangeDocument({ change_id: 11, object_id: "tab-01" }),
syncChangeDocument({ change_id: 12, object_id: "bookmark-01", object_type: "bookmarks" }),
],
});
assert.ok(d1.queries[0]?.includes("approval_status = 'approved'"));
assert.ok(d1.queries[1]?.includes("FROM sync_change_log"));
assert.deepEqual(d1.binds, [
["user-01", "device-01"],
["user-01", 10, 3],
]);
});
it("reports more changes when the pull window is saturated", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [{ device_id: "device-01" }],
allRows: [
syncChangeRow({ change_id: 11, object_id: "tab-01" }),
syncChangeRow({ change_id: 12, object_id: "tab-02" }),
syncChangeRow({ change_id: 13, object_id: "tab-03" }),
],
});
const response = await handleRequest(
new Request("https://elydora.test/api/sync/pull?cursor=10&limit=2", {
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
}),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
}),
);
const body = (await response.json()) as { has_more: boolean; next_cursor: number; changes: [] };
assert.equal(response.status, 200);
assert.equal(body.has_more, true);
assert.equal(body.next_cursor, 12);
assert.equal(body.changes.length, 2);
});
it("rejects revoked devices before reading sync deltas", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({ firstRows: [null], allRows: [syncChangeRow()] });
const response = await handleRequest(
new Request("https://elydora.test/api/sync/pull?cursor=10", {
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
}),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
}),
);
assert.equal(response.status, 403);
assert.deepEqual(await response.json(), { error: "device_not_approved" });
assert.equal(d1.queries.length, 1);
});
it("rejects invalid cursors after session and device validation", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({ firstRows: [{ device_id: "device-01" }] });
const response = await handleRequest(
new Request("https://elydora.test/api/sync/pull?cursor=old", {
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
}),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
}),
);
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { error: "invalid_sync_pull" });
assert.equal(d1.queries.length, 1);
});
it("returns a server error for malformed sync change rows", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [{ device_id: "device-01" }],
allRows: [syncChangeRow({ payload_hash: "bad" })],
});
const response = await handleRequest(
new Request("https://elydora.test/api/sync/pull?cursor=10", {
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
}),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
}),
);
assert.equal(response.status, 500);
assert.deepEqual(await response.json(), { error: "sync_pull_invalid" });
});
it("rejects unauthenticated sync pulls before D1 reads", async () => {
const d1 = testD1Database({ allRows: [syncChangeRow()] });
const response = await handleRequest(
new Request("https://elydora.test/api/sync/pull?cursor=0"),
testEnv({ d1 }),
);
assert.equal(response.status, 401);
assert.deepEqual(await response.json(), { error: "authorization_missing" });
assert.deepEqual(d1.queries, []);
});
});
function syncChangeRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
change_id: 11,
object_id: "tab-01",
object_type: "tabs",
operation: "upsert",
payload_hash: PAYLOAD_HASH,
logical_clock: 42,
device_id: "device-02",
created_at: 1_780_000_500,
...overrides,
};
}
function syncChangeDocument(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return syncChangeRow(overrides);
}