Protect sync pull with approved devices
This commit is contained in:
@@ -13,6 +13,12 @@ const RATE_LIMIT_WINDOW_SECONDS = 60;
|
|||||||
export type ApiHandler = () => Promise<Response>;
|
export type ApiHandler = () => Promise<Response>;
|
||||||
export type AuthenticatedApiHandler = (context: AuthContext) => 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(
|
export async function withPublicApiControls(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
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 {
|
function rateLimitKey(environment: string, route: string): string {
|
||||||
return `${environment}:${route}`;
|
return `${environment}:${route}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-1
@@ -1,5 +1,9 @@
|
|||||||
import type { Env } from "./bindings.js";
|
import type { Env } from "./bindings.js";
|
||||||
import { withAuthenticatedApiControls, withPublicApiControls } from "./api_controls.js";
|
import {
|
||||||
|
withApprovedDeviceApiControls,
|
||||||
|
withAuthenticatedApiControls,
|
||||||
|
withPublicApiControls,
|
||||||
|
} from "./api_controls.js";
|
||||||
import {
|
import {
|
||||||
DevicePermissionError,
|
DevicePermissionError,
|
||||||
DevicePersistenceError,
|
DevicePersistenceError,
|
||||||
@@ -31,6 +35,7 @@ import {
|
|||||||
parsePublicSigningKeysDocument,
|
parsePublicSigningKeysDocument,
|
||||||
publicSigningKeysKvKey,
|
publicSigningKeysKvKey,
|
||||||
} from "./signing_keys.js";
|
} from "./signing_keys.js";
|
||||||
|
import { SyncRequestError, SyncSchemaError, syncPullDocument } from "./sync_pull.js";
|
||||||
import { jsonResponse } from "./responses.js";
|
import { jsonResponse } from "./responses.js";
|
||||||
|
|
||||||
export default {
|
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") {
|
if (url.pathname === "/api/plugins/signing-keys") {
|
||||||
return withPublicApiControls(request, env, "plugins.signing_keys", ["GET"], () =>
|
return withPublicApiControls(request, env, "plugins.signing_keys", ["GET"], () =>
|
||||||
handlePublicSigningKeys(env),
|
handlePublicSigningKeys(env),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user