From 64abebb30cba6f06fb4366eb1a8fb8ecd3cd0414 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Wed, 27 May 2026 16:04:54 -0500 Subject: [PATCH] Harden auth boundaries against reference PDSes --- src/atproto/identity.zig | 26 +++- src/atproto/proxy.zig | 16 ++- src/atproto/repo.zig | 117 +---------------- src/atproto/server.zig | 47 +++++-- src/atproto/sync.zig | 5 + src/internal/scopes.zig | 262 +++++++++++++++++++++++++++++++++++++++ src/root.zig | 1 + 7 files changed, 345 insertions(+), 129 deletions(-) create mode 100644 src/internal/scopes.zig diff --git a/src/atproto/identity.zig b/src/atproto/identity.zig index 4a722d5..e18a68a 100644 --- a/src/atproto/identity.zig +++ b/src/atproto/identity.zig @@ -3,6 +3,7 @@ const auth = @import("../auth/tokens.zig"); const config = @import("../core/config.zig"); const mail = @import("../core/mail.zig"); const http_api = @import("../http/api.zig"); +const scopes = @import("../internal/scopes.zig"); const plc = @import("plc.zig"); const store = @import("../storage/store.zig"); const zat = @import("zat"); @@ -14,7 +15,8 @@ pub fn getRecommendedDidCredentials(request: *http.Server.Request) !void { defer arena.deinit(); const allocator = arena.allocator(); - const account = requireAccount(request, allocator) catch return; + const auth_ctx = requireAccount(request, allocator) catch return; + const account = auth_ctx.account; var keypair = try store.signingKeypair(account.did); const signing_did_key = try keypair.did(allocator); const rotation_keys_json = if (std.mem.startsWith(u8, account.did, "did:web:")) @@ -44,7 +46,9 @@ pub fn requestPlcOperationSignature(request: *http.Server.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); - const account = requireAccount(request, allocator) catch return; + const auth_ctx = requireAccount(request, allocator) catch return; + try requireIdentityScope(request, auth_ctx.oauth_scope, .wildcard); + const account = auth_ctx.account; const info = store.getEmailInfo(allocator, account.did) orelse { return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); }; @@ -60,7 +64,9 @@ pub fn signPlcOperation(request: *http.Server.Request) !void { defer arena.deinit(); const allocator = arena.allocator(); - const account = requireAccount(request, allocator) catch return; + const auth_ctx = requireAccount(request, allocator) catch return; + try requireIdentityScope(request, auth_ctx.oauth_scope, .wildcard); + const account = auth_ctx.account; const body = try http_api.readBodyAlloc(request, allocator, 1024 * 1024); const parsed = try http_api.parseJsonBody(request, allocator, body); const input = switch (parsed.value) { @@ -108,7 +114,9 @@ pub fn submitPlcOperation(request: *http.Server.Request) !void { defer arena.deinit(); const allocator = arena.allocator(); - const account = requireAccount(request, allocator) catch return; + const auth_ctx = requireAccount(request, allocator) catch return; + try requireIdentityScope(request, auth_ctx.oauth_scope, .wildcard); + const account = auth_ctx.account; const body = try http_api.readBodyAlloc(request, allocator, 1024 * 1024); const parsed = try http_api.parseJsonBody(request, allocator, body); const operation = switch (parsed.value) { @@ -173,8 +181,8 @@ pub fn resolveHandle(request: *http.Server.Request) !void { return http_api.json(request, .ok, body); } -fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.Account { - return http_api.requireBearerAccount(request, allocator) catch |err| { +fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !http_api.BearerAccount { + return http_api.requireBearerAccess(request, allocator) catch |err| { switch (err) { error.AuthRequired => try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), @@ -183,6 +191,12 @@ fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) ! }; } +fn requireIdentityScope(request: *http.Server.Request, maybe_scope: ?[]const u8, attr: scopes.IdentityAttr) !void { + if (scopes.identityAllows(maybe_scope, attr)) return; + try http_api.xrpcError(request, .forbidden, "InsufficientScope", "Insufficient scope"); + return error.HandledResponse; +} + fn plcOperationSigningKeypair( request: *http.Server.Request, allocator: std.mem.Allocator, diff --git a/src/atproto/proxy.zig b/src/atproto/proxy.zig index 08845e5..1376be2 100644 --- a/src/atproto/proxy.zig +++ b/src/atproto/proxy.zig @@ -2,6 +2,7 @@ const std = @import("std"); const auth = @import("../auth/tokens.zig"); const log = @import("../core/log.zig"); const http_api = @import("../http/api.zig"); +const scopes = @import("../internal/scopes.zig"); const store = @import("../storage/store.zig"); const zat = @import("zat"); @@ -49,7 +50,7 @@ pub fn xrpcProxy(request: *http.Server.Request) !void { }, }; - const account = http_api.requireBearerAccount(request, allocator) catch |err| switch (err) { + const auth_ctx = http_api.requireBearerAccess(request, allocator) catch |err| switch (err) { error.AuthRequired => { log.debug("xrpc proxy auth missing method={s} proxy_to={s}\n", .{ method, proxy_to }); return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"); @@ -59,6 +60,12 @@ pub fn xrpcProxy(request: *http.Server.Request) !void { return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); }, }; + const scope_aud = try std.fmt.allocPrint(allocator, "{s}#{s}", .{ service.did, service.service_id }); + if (!scopes.rpcAllows(auth_ctx.oauth_scope, scope_aud, method)) { + log.debug("xrpc proxy insufficient_scope method={s} account={s} aud={s}\n", .{ method, auth_ctx.account.did, scope_aud }); + return http_api.xrpcError(request, .forbidden, "InsufficientScope", "Insufficient scope"); + } + const account = auth_ctx.account; log.debug("xrpc proxy auth ok method={s} account={s} service_did={s} endpoint={s}\n", .{ method, account.did, service.did, service.endpoint }); const upstream_url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ service.endpoint, request.head.target }); @@ -112,6 +119,7 @@ pub fn shouldProxy(request: *const http.Server.Request) bool { const ProxyService = struct { did: []const u8, + service_id: []const u8, endpoint: []const u8, }; @@ -119,11 +127,13 @@ fn resolveProxyService(allocator: std.mem.Allocator, proxy_to: []const u8) !Prox const hash = std.mem.indexOfScalar(u8, proxy_to, '#') orelse return error.InvalidProxyHeader; if (hash == 0 or hash + 1 >= proxy_to.len) return error.InvalidProxyHeader; const did = proxy_to[0..hash]; - const service_id = proxy_to[hash..]; + const service_id = proxy_to[hash + 1 ..]; const did_doc = try fetchDidDocument(allocator, did); - const endpoint = serviceEndpoint(did_doc.value, service_id) orelse return error.ServiceNotFound; + const service_ref = try std.fmt.allocPrint(allocator, "#{s}", .{service_id}); + const endpoint = serviceEndpoint(did_doc.value, service_ref) orelse return error.ServiceNotFound; return .{ .did = try allocator.dupe(u8, did), + .service_id = try allocator.dupe(u8, service_id), .endpoint = try allocator.dupe(u8, std.mem.trim(u8, endpoint, "/")), }; } diff --git a/src/atproto/repo.zig b/src/atproto/repo.zig index 2f0d7b3..402940f 100644 --- a/src/atproto/repo.zig +++ b/src/atproto/repo.zig @@ -2,14 +2,13 @@ const std = @import("std"); const auth = @import("../auth/tokens.zig"); const config = @import("../core/config.zig"); const http_api = @import("../http/api.zig"); +const scopes = @import("../internal/scopes.zig"); const store = @import("../storage/store.zig"); const sync = @import("sync.zig"); const zat = @import("zat"); const http = std.http; -const RepoAction = enum { create, update, delete }; - pub fn createRecord(request: *http.Server.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); @@ -528,124 +527,16 @@ fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) ! }; } -fn requireRepoScope(request: *http.Server.Request, maybe_scope: ?[]const u8, action: RepoAction, collection: []const u8) !void { - const scope = maybe_scope orelse return; - if (repoScopeAllows(scope, action, collection)) return; +fn requireRepoScope(request: *http.Server.Request, maybe_scope: ?[]const u8, action: scopes.RepoAction, collection: []const u8) !void { + if (scopes.repoAllows(maybe_scope, action, collection)) return; return http_api.xrpcError(request, .forbidden, "InsufficientScope", "Insufficient scope"); } fn requireBlobScope(request: *http.Server.Request, maybe_scope: ?[]const u8, mime_type: []const u8) !void { - const scope = maybe_scope orelse return; - if (blobScopeAllows(scope, mime_type)) return; + if (scopes.blobAllows(maybe_scope, mime_type)) return; return http_api.xrpcError(request, .forbidden, "InsufficientScope", "Insufficient scope"); } -fn repoScopeAllows(scope_text: []const u8, action: RepoAction, collection: []const u8) bool { - var scopes = std.mem.splitScalar(u8, scope_text, ' '); - while (scopes.next()) |scope| { - if (scope.len == 0) continue; - if (std.mem.eql(u8, scope, "transition:generic")) return true; - if (!std.mem.startsWith(u8, scope, "repo")) continue; - if (repoScopeMatches(scope, action, collection)) return true; - } - return false; -} - -fn blobScopeAllows(scope_text: []const u8, mime_type: []const u8) bool { - var scopes = std.mem.splitScalar(u8, scope_text, ' '); - while (scopes.next()) |scope| { - if (scope.len == 0) continue; - if (std.mem.eql(u8, scope, "transition:generic")) return true; - if (!std.mem.startsWith(u8, scope, "blob")) continue; - if (blobScopeMatches(scope, mime_type)) return true; - } - return false; -} - -fn blobScopeMatches(scope: []const u8, mime_type: []const u8) bool { - const query_start = std.mem.indexOfScalar(u8, scope, '?'); - const base = if (query_start) |idx| scope[0..idx] else scope; - if (blobMimeMatches(blobScopePattern(base), mime_type)) return true; - if (query_start == null) return false; - - var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); - while (params.next()) |param| { - if (!std.mem.startsWith(u8, param, "accept=")) continue; - if (blobMimeMatches(param["accept=".len..], mime_type)) return true; - } - return false; -} - -fn blobScopePattern(base: []const u8) []const u8 { - if (std.mem.eql(u8, base, "blob") or std.mem.eql(u8, base, "blob:")) return "*/*"; - if (!std.mem.startsWith(u8, base, "blob:")) return ""; - return base["blob:".len..]; -} - -fn blobMimeMatches(pattern: []const u8, mime_type: []const u8) bool { - if (pattern.len == 0) return false; - if (std.mem.eql(u8, pattern, "*/*")) return true; - if (std.mem.eql(u8, pattern, mime_type)) return true; - if (std.mem.endsWith(u8, pattern, "/*")) { - const prefix = pattern[0 .. pattern.len - 2]; - return std.mem.startsWith(u8, mime_type, prefix) and mime_type.len > prefix.len and mime_type[prefix.len] == '/'; - } - return false; -} - -fn repoScopeMatches(scope: []const u8, action: RepoAction, collection: []const u8) bool { - const query_start = std.mem.indexOfScalar(u8, scope, '?'); - const base = if (query_start) |idx| scope[0..idx] else scope; - if (!repoScopeCollectionMatches(base, collection)) return false; - if (query_start == null) return true; - - var saw_action = false; - var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); - while (params.next()) |param| { - if (!std.mem.startsWith(u8, param, "action=")) continue; - saw_action = true; - if (std.mem.eql(u8, param["action=".len..], repoActionName(action))) return true; - } - return !saw_action; -} - -fn repoScopeCollectionMatches(base: []const u8, collection: []const u8) bool { - if (std.mem.eql(u8, base, "repo") or std.mem.eql(u8, base, "repo:") or std.mem.eql(u8, base, "repo:*")) return true; - if (!std.mem.startsWith(u8, base, "repo:")) return false; - const allowed = base["repo:".len..]; - if (std.mem.eql(u8, allowed, collection)) return true; - if (std.mem.endsWith(u8, allowed, ".*")) { - const prefix = allowed[0 .. allowed.len - 2]; - return std.mem.startsWith(u8, collection, prefix) and collection.len > prefix.len and collection[prefix.len] == '.'; - } - return false; -} - -fn repoActionName(action: RepoAction) []const u8 { - return switch (action) { - .create => "create", - .update => "update", - .delete => "delete", - }; -} - -test "repo scope permissions enforce collection and action" { - try std.testing.expect(repoScopeAllows("transition:generic", .create, "earth.cirrus.check.othertestrecord")); - try std.testing.expect(repoScopeAllows("repo:*?action=create", .create, "earth.cirrus.check.othertestrecord")); - try std.testing.expect(!repoScopeAllows("repo:*?action=create", .update, "earth.cirrus.check.othertestrecord")); - try std.testing.expect(repoScopeAllows("repo:earth.cirrus.check.testrecord?action=create", .create, "earth.cirrus.check.testrecord")); - try std.testing.expect(!repoScopeAllows("repo:earth.cirrus.check.testrecord?action=create", .create, "earth.cirrus.check.othertestrecord")); - try std.testing.expect(repoScopeAllows("repo:earth.cirrus.check.*?action=delete", .delete, "earth.cirrus.check.othertestrecord")); -} - -test "blob scope permissions enforce mime type" { - try std.testing.expect(blobScopeAllows("transition:generic", "image/png")); - try std.testing.expect(blobScopeAllows("blob:*/*", "image/png")); - try std.testing.expect(blobScopeAllows("blob:image/*", "image/jpeg")); - try std.testing.expect(blobScopeAllows("blob?accept=image/*&accept=video/*", "video/mp4")); - try std.testing.expect(!blobScopeAllows("blob?accept=image/*", "text/plain")); -} - fn requireRepoMatches(request: *http.Server.Request, account: auth.Account, value: std.json.Value) !void { const repo = zat.json.getString(value, "repo") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); diff --git a/src/atproto/server.zig b/src/atproto/server.zig index 6cdef33..20ae31f 100644 --- a/src/atproto/server.zig +++ b/src/atproto/server.zig @@ -6,6 +6,7 @@ const mail = @import("../core/mail.zig"); const plc = @import("plc.zig"); const sync = @import("sync.zig"); const http_api = @import("../http/api.zig"); +const scopes = @import("../internal/scopes.zig"); const store = @import("../storage/store.zig"); const zat = @import("zat"); @@ -689,16 +690,17 @@ pub fn getServiceAuth(request: *http.Server.Request) !void { defer arena.deinit(); const allocator = arena.allocator(); - const account = http_api.requireBearerAccount(request, allocator) catch |err| switch (err) { + const auth_ctx = http_api.requireBearerAccess(request, allocator) catch |err| switch (err) { error.AuthRequired => return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), error.InvalidToken => return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), }; + const account = auth_ctx.account; var aud_buf: [256]u8 = undefined; const audience = http_api.queryParam(request.head.target, "aud", &aud_buf) orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing aud"); }; - if (zat.Did.parse(audience) == null) { + if (!validServiceAuthAudience(audience)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid aud"); } @@ -711,6 +713,11 @@ pub fn getServiceAuth(request: *http.Server.Request) !void { if (serviceAuthProtectedMethod(method)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Cannot request service auth for protected method"); } + if (!scopes.rpcAllows(auth_ctx.oauth_scope, audience, method)) { + return http_api.xrpcError(request, .forbidden, "InsufficientScope", "Insufficient scope"); + } + } else if (auth_ctx.oauth_scope != null and !scopes.hasFullAccess(auth_ctx.oauth_scope)) { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "OAuth tokens with granular scopes must specify an lxm parameter"); } const requested_exp = try requestedServiceAuthExpiration(request, lxm != null); @@ -721,6 +728,14 @@ pub fn getServiceAuth(request: *http.Server.Request) !void { return http_api.json(request, .ok, body_out); } +fn validServiceAuthAudience(audience: []const u8) bool { + if (zat.Did.parse(audience) != null) return true; + const hash = std.mem.indexOfScalar(u8, audience, '#') orelse return false; + if (hash == 0 or hash + 1 >= audience.len) return false; + if (std.mem.indexOfScalarPos(u8, audience, hash + 1, '#') != null) return false; + return zat.Did.parse(audience[0..hash]) != null; +} + fn requestedServiceAuthExpiration(request: *http.Server.Request, has_lxm: bool) !?i64 { var exp_buf: [32]u8 = undefined; const raw = http_api.queryParam(request.head.target, "exp", &exp_buf) orelse return null; @@ -782,7 +797,9 @@ pub fn requestEmailConfirmation(request: *http.Server.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); - const account = requireAccount(request, allocator) catch return; + const auth_ctx = requireAccountAccess(request, allocator) catch return; + try requireAccountScope(request, auth_ctx.oauth_scope, .email, .manage); + const account = auth_ctx.account; const info = store.getEmailInfo(allocator, account.did) orelse { return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); }; @@ -800,7 +817,9 @@ pub fn confirmEmail(request: *http.Server.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); - const account = requireAccount(request, allocator) catch return; + const auth_ctx = requireAccountAccess(request, allocator) catch return; + try requireAccountScope(request, auth_ctx.oauth_scope, .email, .manage); + const account = auth_ctx.account; var body_buf: [4096]u8 = undefined; const body = try http_api.readBody(request, &body_buf); const parsed = try http_api.parseJsonBody(request, allocator, body); @@ -828,7 +847,9 @@ pub fn requestEmailUpdate(request: *http.Server.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); - const account = requireAccount(request, allocator) catch return; + const auth_ctx = requireAccountAccess(request, allocator) catch return; + try requireAccountScope(request, auth_ctx.oauth_scope, .email, .manage); + const account = auth_ctx.account; const info = store.getEmailInfo(allocator, account.did) orelse { return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); }; @@ -846,7 +867,9 @@ pub fn updateEmail(request: *http.Server.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); - const account = requireAccount(request, allocator) catch return; + const auth_ctx = requireAccountAccess(request, allocator) catch return; + try requireAccountScope(request, auth_ctx.oauth_scope, .email, .manage); + const account = auth_ctx.account; var body_buf: [4096]u8 = undefined; const body = try http_api.readBody(request, &body_buf); const parsed = try http_api.parseJsonBody(request, allocator, body); @@ -883,7 +906,11 @@ pub fn checkAccountStatus(request: *http.Server.Request) !void { } fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.Account { - return http_api.requireBearerAccount(request, allocator) catch |err| { + return (try requireAccountAccess(request, allocator)).account; +} + +fn requireAccountAccess(request: *http.Server.Request, allocator: std.mem.Allocator) !http_api.BearerAccount { + return http_api.requireBearerAccess(request, allocator) catch |err| { switch (err) { error.AuthRequired => try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), @@ -892,6 +919,12 @@ fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) ! }; } +fn requireAccountScope(request: *http.Server.Request, maybe_scope: ?[]const u8, attr: scopes.AccountAttr, action: scopes.AccountAction) !void { + if (scopes.accountAllows(maybe_scope, attr, action)) return; + try http_api.xrpcError(request, .forbidden, "InsufficientScope", "Insufficient scope"); + return error.HandledResponse; +} + fn requireAdminToken(request: *http.Server.Request) !void { const expected = config.adminToken() orelse { try http_api.xrpcError(request, .forbidden, "AdminRequired", "Admin token not configured"); diff --git a/src/atproto/sync.zig b/src/atproto/sync.zig index df1ab09..04da676 100644 --- a/src/atproto/sync.zig +++ b/src/atproto/sync.zig @@ -27,10 +27,15 @@ pub fn getBlob(request: *http.Server.Request) !void { const blob = store.getBlob(allocator, did, cid) orelse { return http_api.xrpcError(request, .not_found, "BlobNotFound", "Blob not found"); }; + const content_length = try std.fmt.allocPrint(allocator, "{d}", .{blob.data.len}); + const disposition = try std.fmt.allocPrint(allocator, "attachment; filename=\"{s}\"", .{cid}); const headers = [_]http.Header{ .{ .name = "content-type", .value = blob.mime_type }, + .{ .name = "content-length", .value = content_length }, .{ .name = "cache-control", .value = "public, max-age=31536000, immutable" }, .{ .name = "x-content-type-options", .value = "nosniff" }, + .{ .name = "content-disposition", .value = disposition }, + .{ .name = "content-security-policy", .value = "default-src 'none'; sandbox" }, .{ .name = "access-control-allow-origin", .value = "*" }, .{ .name = "access-control-allow-private-network", .value = "true" }, .{ .name = "connection", .value = "close" }, diff --git a/src/internal/scopes.zig b/src/internal/scopes.zig new file mode 100644 index 0000000..d16f84d --- /dev/null +++ b/src/internal/scopes.zig @@ -0,0 +1,262 @@ +const std = @import("std"); + +pub const RepoAction = enum { create, update, delete }; +pub const AccountAction = enum { read, manage }; +pub const AccountAttr = enum { email, repo, status, wildcard }; +pub const IdentityAttr = enum { handle, wildcard }; + +pub fn oauthNeedsGranularCheck(maybe_scope: ?[]const u8) bool { + const scope_text = maybe_scope orelse return false; + return !std.mem.eql(u8, scope_text, "com.atproto.access") and + !std.mem.eql(u8, scope_text, "atproto"); +} + +pub fn repoAllows(maybe_scope: ?[]const u8, action: RepoAction, collection: []const u8) bool { + if (!oauthNeedsGranularCheck(maybe_scope)) return true; + var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); + while (scopes.next()) |scope| { + if (scope.len == 0) continue; + if (std.mem.eql(u8, scope, "transition:generic")) return true; + if (!std.mem.startsWith(u8, scope, "repo")) continue; + if (repoScopeMatches(scope, action, collection)) return true; + } + return false; +} + +pub fn blobAllows(maybe_scope: ?[]const u8, mime_type: []const u8) bool { + if (!oauthNeedsGranularCheck(maybe_scope)) return true; + var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); + while (scopes.next()) |scope| { + if (scope.len == 0) continue; + if (std.mem.eql(u8, scope, "transition:generic")) return true; + if (!std.mem.startsWith(u8, scope, "blob")) continue; + if (blobScopeMatches(scope, mime_type)) return true; + } + return false; +} + +pub fn rpcAllows(maybe_scope: ?[]const u8, aud: []const u8, lxm: []const u8) bool { + if (!oauthNeedsGranularCheck(maybe_scope)) return true; + var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); + while (scopes.next()) |scope| { + if (scope.len == 0) continue; + if (std.mem.eql(u8, scope, "transition:generic")) return true; + if (!std.mem.startsWith(u8, scope, "rpc:")) continue; + if (rpcScopeMatches(scope, aud, lxm)) return true; + } + return false; +} + +pub fn accountAllows(maybe_scope: ?[]const u8, attr: AccountAttr, action: AccountAction) bool { + if (!oauthNeedsGranularCheck(maybe_scope)) return true; + var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); + while (scopes.next()) |scope| { + if (scope.len == 0) continue; + if (std.mem.eql(u8, scope, "transition:generic")) return true; + if (!std.mem.startsWith(u8, scope, "account")) continue; + if (accountScopeMatches(scope, attr, action)) return true; + } + return false; +} + +pub fn identityAllows(maybe_scope: ?[]const u8, attr: IdentityAttr) bool { + if (!oauthNeedsGranularCheck(maybe_scope)) return true; + var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); + while (scopes.next()) |scope| { + if (scope.len == 0) continue; + if (std.mem.eql(u8, scope, "transition:generic")) return true; + if (!std.mem.startsWith(u8, scope, "identity")) continue; + if (identityScopeMatches(scope, attr)) return true; + } + return false; +} + +pub fn hasFullAccess(maybe_scope: ?[]const u8) bool { + if (!oauthNeedsGranularCheck(maybe_scope)) return true; + var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); + while (scopes.next()) |scope| { + if (std.mem.eql(u8, scope, "atproto") or std.mem.eql(u8, scope, "transition:generic")) return true; + } + return false; +} + +fn accountScopeMatches(scope: []const u8, attr: AccountAttr, action: AccountAction) bool { + const query_start = std.mem.indexOfScalar(u8, scope, '?'); + const base = if (query_start) |idx| scope[0..idx] else scope; + if (!accountAttrMatches(base, attr)) return false; + if (query_start == null) return true; + + var saw_action = false; + var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); + while (params.next()) |param| { + if (!std.mem.startsWith(u8, param, "action=")) continue; + saw_action = true; + if (std.mem.eql(u8, param["action=".len..], accountActionName(action))) return true; + } + return !saw_action; +} + +fn accountAttrMatches(base: []const u8, attr: AccountAttr) bool { + if (std.mem.eql(u8, base, "account") or std.mem.eql(u8, base, "account:") or std.mem.eql(u8, base, "account:*")) return true; + if (!std.mem.startsWith(u8, base, "account:")) return false; + const allowed = base["account:".len..]; + if (attr == .wildcard) return std.mem.eql(u8, allowed, "*"); + return std.mem.eql(u8, allowed, accountAttrName(attr)); +} + +fn accountActionName(action: AccountAction) []const u8 { + return switch (action) { + .read => "read", + .manage => "manage", + }; +} + +fn accountAttrName(attr: AccountAttr) []const u8 { + return switch (attr) { + .email => "email", + .repo => "repo", + .status => "status", + .wildcard => "*", + }; +} + +fn identityScopeMatches(scope: []const u8, attr: IdentityAttr) bool { + const query_start = std.mem.indexOfScalar(u8, scope, '?'); + const base = if (query_start) |idx| scope[0..idx] else scope; + if (std.mem.eql(u8, base, "identity") or std.mem.eql(u8, base, "identity:") or std.mem.eql(u8, base, "identity:*")) return true; + if (!std.mem.startsWith(u8, base, "identity:")) return false; + const allowed = base["identity:".len..]; + return switch (attr) { + .handle => std.mem.eql(u8, allowed, "handle"), + .wildcard => false, + }; +} + +fn repoScopeMatches(scope: []const u8, action: RepoAction, collection: []const u8) bool { + const query_start = std.mem.indexOfScalar(u8, scope, '?'); + const base = if (query_start) |idx| scope[0..idx] else scope; + if (!repoScopeCollectionMatches(base, collection)) return false; + if (query_start == null) return true; + + var saw_action = false; + var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); + while (params.next()) |param| { + if (!std.mem.startsWith(u8, param, "action=")) continue; + saw_action = true; + if (std.mem.eql(u8, param["action=".len..], repoActionName(action))) return true; + } + return !saw_action; +} + +fn repoScopeCollectionMatches(base: []const u8, collection: []const u8) bool { + if (std.mem.eql(u8, base, "repo") or std.mem.eql(u8, base, "repo:") or std.mem.eql(u8, base, "repo:*")) return true; + if (!std.mem.startsWith(u8, base, "repo:")) return false; + const allowed = base["repo:".len..]; + if (std.mem.eql(u8, allowed, collection)) return true; + if (std.mem.endsWith(u8, allowed, ".*")) { + const prefix = allowed[0 .. allowed.len - 2]; + return std.mem.startsWith(u8, collection, prefix) and collection.len > prefix.len and collection[prefix.len] == '.'; + } + return false; +} + +fn repoActionName(action: RepoAction) []const u8 { + return switch (action) { + .create => "create", + .update => "update", + .delete => "delete", + }; +} + +fn blobScopeMatches(scope: []const u8, mime_type: []const u8) bool { + const query_start = std.mem.indexOfScalar(u8, scope, '?'); + const base = if (query_start) |idx| scope[0..idx] else scope; + if (blobMimeMatches(blobScopePattern(base), mime_type)) return true; + if (query_start == null) return false; + + var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); + while (params.next()) |param| { + if (!std.mem.startsWith(u8, param, "accept=")) continue; + if (blobMimeMatches(param["accept=".len..], mime_type)) return true; + } + return false; +} + +fn blobScopePattern(base: []const u8) []const u8 { + if (std.mem.eql(u8, base, "blob") or std.mem.eql(u8, base, "blob:")) return "*/*"; + if (!std.mem.startsWith(u8, base, "blob:")) return ""; + return base["blob:".len..]; +} + +fn blobMimeMatches(pattern: []const u8, mime_type: []const u8) bool { + if (pattern.len == 0) return false; + if (std.mem.eql(u8, pattern, "*/*")) return true; + if (std.mem.eql(u8, pattern, mime_type)) return true; + if (std.mem.endsWith(u8, pattern, "/*")) { + const prefix = pattern[0 .. pattern.len - 2]; + return std.mem.startsWith(u8, mime_type, prefix) and mime_type.len > prefix.len and mime_type[prefix.len] == '/'; + } + return false; +} + +fn rpcScopeMatches(scope: []const u8, aud: []const u8, lxm: []const u8) bool { + const query_start = std.mem.indexOfScalar(u8, scope, '?'); + const base = if (query_start) |idx| scope[0..idx] else scope; + if (!rpcMethodMatches(base, lxm)) return false; + if (query_start == null) return false; + + var saw_aud = false; + var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); + while (params.next()) |param| { + if (!std.mem.startsWith(u8, param, "aud=")) continue; + saw_aud = true; + const allowed = param["aud=".len..]; + if (std.mem.eql(u8, allowed, "*") or std.mem.eql(u8, allowed, aud)) return true; + } + return !saw_aud; +} + +fn rpcMethodMatches(base: []const u8, lxm: []const u8) bool { + if (!std.mem.startsWith(u8, base, "rpc:")) return false; + const allowed = base["rpc:".len..]; + if (std.mem.eql(u8, allowed, "*") or std.mem.eql(u8, allowed, lxm)) return true; + if (std.mem.endsWith(u8, allowed, ".*")) { + const prefix = allowed[0 .. allowed.len - 2]; + return std.mem.startsWith(u8, lxm, prefix) and lxm.len > prefix.len and lxm[prefix.len] == '.'; + } + return false; +} + +test "repo scopes constrain action and collection" { + try std.testing.expect(repoAllows("repo:*?action=create", .create, "app.bsky.feed.post")); + try std.testing.expect(!repoAllows("repo:*?action=create", .delete, "app.bsky.feed.post")); + try std.testing.expect(repoAllows("repo:app.bsky.feed.*", .update, "app.bsky.feed.like")); + try std.testing.expect(!repoAllows("repo:app.bsky.feed.*", .update, "app.bsky.graph.follow")); +} + +test "blob scopes constrain mime types" { + try std.testing.expect(blobAllows("blob:image/*", "image/jpeg")); + try std.testing.expect(!blobAllows("blob:image/*", "video/mp4")); + try std.testing.expect(blobAllows("blob?accept=image/*&accept=video/*", "video/mp4")); +} + +test "rpc scopes constrain method and audience" { + try std.testing.expect(rpcAllows("rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app#bsky_appview", "did:web:api.bsky.app#bsky_appview", "app.bsky.feed.getTimeline")); + try std.testing.expect(rpcAllows("rpc:*?aud=did:web:api.bsky.app#bsky_appview", "did:web:api.bsky.app#bsky_appview", "app.bsky.feed.getFeed")); + try std.testing.expect(rpcAllows("rpc:app.bsky.feed.*?aud=*", "did:web:api.bsky.app#bsky_appview", "app.bsky.feed.getFeed")); + try std.testing.expect(!rpcAllows("rpc:app.bsky.feed.*?aud=did:web:api.bsky.app#bsky_appview", "did:web:video.bsky.app#video", "app.bsky.feed.getFeed")); + try std.testing.expect(!rpcAllows("repo:*", "did:web:api.bsky.app#bsky_appview", "app.bsky.feed.getFeed")); +} + +test "account scopes constrain attribute and action" { + try std.testing.expect(accountAllows("account:email?action=manage", .email, .manage)); + try std.testing.expect(!accountAllows("account:email?action=read", .email, .manage)); + try std.testing.expect(!accountAllows("account:repo?action=manage", .email, .manage)); + try std.testing.expect(accountAllows("account:*", .repo, .manage)); +} + +test "identity scopes constrain attribute" { + try std.testing.expect(identityAllows("identity:handle", .handle)); + try std.testing.expect(!identityAllows("identity:handle", .wildcard)); + try std.testing.expect(identityAllows("identity:*", .wildcard)); +} diff --git a/src/root.zig b/src/root.zig index cba831d..5cda1f2 100644 --- a/src/root.zig +++ b/src/root.zig @@ -32,6 +32,7 @@ pub const http = struct { pub const internal = struct { pub const cli = @import("internal/cli.zig"); pub const passkeys = @import("internal/passkeys.zig"); + pub const scopes = @import("internal/scopes.zig"); pub const sharded_locks = @import("internal/sharded_locks.zig"); }; -- 2.51.2