atproto pds in zig pds.zat.dev
pds atproto
24

Configure Feed

Select the types of activity you want to include in your feed.

Harden auth boundaries against reference PDSes

zzstoatzz (May 27, 2026, 4:04 PM -0500) 64abebb3 81eb9630

+345 -129
+20 -6
src/atproto/identity.zig
··· 3 3 const config = @import("../core/config.zig"); 4 4 const mail = @import("../core/mail.zig"); 5 5 const http_api = @import("../http/api.zig"); 6 + const scopes = @import("../internal/scopes.zig"); 6 7 const plc = @import("plc.zig"); 7 8 const store = @import("../storage/store.zig"); 8 9 const zat = @import("zat"); ··· 14 15 defer arena.deinit(); 15 16 const allocator = arena.allocator(); 16 17 17 - const account = requireAccount(request, allocator) catch return; 18 + const auth_ctx = requireAccount(request, allocator) catch return; 19 + const account = auth_ctx.account; 18 20 var keypair = try store.signingKeypair(account.did); 19 21 const signing_did_key = try keypair.did(allocator); 20 22 const rotation_keys_json = if (std.mem.startsWith(u8, account.did, "did:web:")) ··· 44 46 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 45 47 defer arena.deinit(); 46 48 const allocator = arena.allocator(); 47 - const account = requireAccount(request, allocator) catch return; 49 + const auth_ctx = requireAccount(request, allocator) catch return; 50 + try requireIdentityScope(request, auth_ctx.oauth_scope, .wildcard); 51 + const account = auth_ctx.account; 48 52 const info = store.getEmailInfo(allocator, account.did) orelse { 49 53 return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 50 54 }; ··· 60 64 defer arena.deinit(); 61 65 const allocator = arena.allocator(); 62 66 63 - const account = requireAccount(request, allocator) catch return; 67 + const auth_ctx = requireAccount(request, allocator) catch return; 68 + try requireIdentityScope(request, auth_ctx.oauth_scope, .wildcard); 69 + const account = auth_ctx.account; 64 70 const body = try http_api.readBodyAlloc(request, allocator, 1024 * 1024); 65 71 const parsed = try http_api.parseJsonBody(request, allocator, body); 66 72 const input = switch (parsed.value) { ··· 108 114 defer arena.deinit(); 109 115 const allocator = arena.allocator(); 110 116 111 - const account = requireAccount(request, allocator) catch return; 117 + const auth_ctx = requireAccount(request, allocator) catch return; 118 + try requireIdentityScope(request, auth_ctx.oauth_scope, .wildcard); 119 + const account = auth_ctx.account; 112 120 const body = try http_api.readBodyAlloc(request, allocator, 1024 * 1024); 113 121 const parsed = try http_api.parseJsonBody(request, allocator, body); 114 122 const operation = switch (parsed.value) { ··· 173 181 return http_api.json(request, .ok, body); 174 182 } 175 183 176 - fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.Account { 177 - return http_api.requireBearerAccount(request, allocator) catch |err| { 184 + fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !http_api.BearerAccount { 185 + return http_api.requireBearerAccess(request, allocator) catch |err| { 178 186 switch (err) { 179 187 error.AuthRequired => try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), 180 188 error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), 181 189 } 182 190 return error.HandledResponse; 183 191 }; 192 + } 193 + 194 + fn requireIdentityScope(request: *http.Server.Request, maybe_scope: ?[]const u8, attr: scopes.IdentityAttr) !void { 195 + if (scopes.identityAllows(maybe_scope, attr)) return; 196 + try http_api.xrpcError(request, .forbidden, "InsufficientScope", "Insufficient scope"); 197 + return error.HandledResponse; 184 198 } 185 199 186 200 fn plcOperationSigningKeypair(
+13 -3
src/atproto/proxy.zig
··· 2 2 const auth = @import("../auth/tokens.zig"); 3 3 const log = @import("../core/log.zig"); 4 4 const http_api = @import("../http/api.zig"); 5 + const scopes = @import("../internal/scopes.zig"); 5 6 const store = @import("../storage/store.zig"); 6 7 const zat = @import("zat"); 7 8 ··· 49 50 }, 50 51 }; 51 52 52 - const account = http_api.requireBearerAccount(request, allocator) catch |err| switch (err) { 53 + const auth_ctx = http_api.requireBearerAccess(request, allocator) catch |err| switch (err) { 53 54 error.AuthRequired => { 54 55 log.debug("xrpc proxy auth missing method={s} proxy_to={s}\n", .{ method, proxy_to }); 55 56 return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"); ··· 59 60 return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 60 61 }, 61 62 }; 63 + const scope_aud = try std.fmt.allocPrint(allocator, "{s}#{s}", .{ service.did, service.service_id }); 64 + if (!scopes.rpcAllows(auth_ctx.oauth_scope, scope_aud, method)) { 65 + log.debug("xrpc proxy insufficient_scope method={s} account={s} aud={s}\n", .{ method, auth_ctx.account.did, scope_aud }); 66 + return http_api.xrpcError(request, .forbidden, "InsufficientScope", "Insufficient scope"); 67 + } 68 + const account = auth_ctx.account; 62 69 log.debug("xrpc proxy auth ok method={s} account={s} service_did={s} endpoint={s}\n", .{ method, account.did, service.did, service.endpoint }); 63 70 const upstream_url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ service.endpoint, request.head.target }); 64 71 ··· 112 119 113 120 const ProxyService = struct { 114 121 did: []const u8, 122 + service_id: []const u8, 115 123 endpoint: []const u8, 116 124 }; 117 125 ··· 119 127 const hash = std.mem.indexOfScalar(u8, proxy_to, '#') orelse return error.InvalidProxyHeader; 120 128 if (hash == 0 or hash + 1 >= proxy_to.len) return error.InvalidProxyHeader; 121 129 const did = proxy_to[0..hash]; 122 - const service_id = proxy_to[hash..]; 130 + const service_id = proxy_to[hash + 1 ..]; 123 131 const did_doc = try fetchDidDocument(allocator, did); 124 - const endpoint = serviceEndpoint(did_doc.value, service_id) orelse return error.ServiceNotFound; 132 + const service_ref = try std.fmt.allocPrint(allocator, "#{s}", .{service_id}); 133 + const endpoint = serviceEndpoint(did_doc.value, service_ref) orelse return error.ServiceNotFound; 125 134 return .{ 126 135 .did = try allocator.dupe(u8, did), 136 + .service_id = try allocator.dupe(u8, service_id), 127 137 .endpoint = try allocator.dupe(u8, std.mem.trim(u8, endpoint, "/")), 128 138 }; 129 139 }
+4 -113
src/atproto/repo.zig
··· 2 2 const auth = @import("../auth/tokens.zig"); 3 3 const config = @import("../core/config.zig"); 4 4 const http_api = @import("../http/api.zig"); 5 + const scopes = @import("../internal/scopes.zig"); 5 6 const store = @import("../storage/store.zig"); 6 7 const sync = @import("sync.zig"); 7 8 const zat = @import("zat"); 8 9 9 10 const http = std.http; 10 - 11 - const RepoAction = enum { create, update, delete }; 12 11 13 12 pub fn createRecord(request: *http.Server.Request) !void { 14 13 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); ··· 528 527 }; 529 528 } 530 529 531 - fn requireRepoScope(request: *http.Server.Request, maybe_scope: ?[]const u8, action: RepoAction, collection: []const u8) !void { 532 - const scope = maybe_scope orelse return; 533 - if (repoScopeAllows(scope, action, collection)) return; 530 + fn requireRepoScope(request: *http.Server.Request, maybe_scope: ?[]const u8, action: scopes.RepoAction, collection: []const u8) !void { 531 + if (scopes.repoAllows(maybe_scope, action, collection)) return; 534 532 return http_api.xrpcError(request, .forbidden, "InsufficientScope", "Insufficient scope"); 535 533 } 536 534 537 535 fn requireBlobScope(request: *http.Server.Request, maybe_scope: ?[]const u8, mime_type: []const u8) !void { 538 - const scope = maybe_scope orelse return; 539 - if (blobScopeAllows(scope, mime_type)) return; 536 + if (scopes.blobAllows(maybe_scope, mime_type)) return; 540 537 return http_api.xrpcError(request, .forbidden, "InsufficientScope", "Insufficient scope"); 541 - } 542 - 543 - fn repoScopeAllows(scope_text: []const u8, action: RepoAction, collection: []const u8) bool { 544 - var scopes = std.mem.splitScalar(u8, scope_text, ' '); 545 - while (scopes.next()) |scope| { 546 - if (scope.len == 0) continue; 547 - if (std.mem.eql(u8, scope, "transition:generic")) return true; 548 - if (!std.mem.startsWith(u8, scope, "repo")) continue; 549 - if (repoScopeMatches(scope, action, collection)) return true; 550 - } 551 - return false; 552 - } 553 - 554 - fn blobScopeAllows(scope_text: []const u8, mime_type: []const u8) bool { 555 - var scopes = std.mem.splitScalar(u8, scope_text, ' '); 556 - while (scopes.next()) |scope| { 557 - if (scope.len == 0) continue; 558 - if (std.mem.eql(u8, scope, "transition:generic")) return true; 559 - if (!std.mem.startsWith(u8, scope, "blob")) continue; 560 - if (blobScopeMatches(scope, mime_type)) return true; 561 - } 562 - return false; 563 - } 564 - 565 - fn blobScopeMatches(scope: []const u8, mime_type: []const u8) bool { 566 - const query_start = std.mem.indexOfScalar(u8, scope, '?'); 567 - const base = if (query_start) |idx| scope[0..idx] else scope; 568 - if (blobMimeMatches(blobScopePattern(base), mime_type)) return true; 569 - if (query_start == null) return false; 570 - 571 - var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); 572 - while (params.next()) |param| { 573 - if (!std.mem.startsWith(u8, param, "accept=")) continue; 574 - if (blobMimeMatches(param["accept=".len..], mime_type)) return true; 575 - } 576 - return false; 577 - } 578 - 579 - fn blobScopePattern(base: []const u8) []const u8 { 580 - if (std.mem.eql(u8, base, "blob") or std.mem.eql(u8, base, "blob:")) return "*/*"; 581 - if (!std.mem.startsWith(u8, base, "blob:")) return ""; 582 - return base["blob:".len..]; 583 - } 584 - 585 - fn blobMimeMatches(pattern: []const u8, mime_type: []const u8) bool { 586 - if (pattern.len == 0) return false; 587 - if (std.mem.eql(u8, pattern, "*/*")) return true; 588 - if (std.mem.eql(u8, pattern, mime_type)) return true; 589 - if (std.mem.endsWith(u8, pattern, "/*")) { 590 - const prefix = pattern[0 .. pattern.len - 2]; 591 - return std.mem.startsWith(u8, mime_type, prefix) and mime_type.len > prefix.len and mime_type[prefix.len] == '/'; 592 - } 593 - return false; 594 - } 595 - 596 - fn repoScopeMatches(scope: []const u8, action: RepoAction, collection: []const u8) bool { 597 - const query_start = std.mem.indexOfScalar(u8, scope, '?'); 598 - const base = if (query_start) |idx| scope[0..idx] else scope; 599 - if (!repoScopeCollectionMatches(base, collection)) return false; 600 - if (query_start == null) return true; 601 - 602 - var saw_action = false; 603 - var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); 604 - while (params.next()) |param| { 605 - if (!std.mem.startsWith(u8, param, "action=")) continue; 606 - saw_action = true; 607 - if (std.mem.eql(u8, param["action=".len..], repoActionName(action))) return true; 608 - } 609 - return !saw_action; 610 - } 611 - 612 - fn repoScopeCollectionMatches(base: []const u8, collection: []const u8) bool { 613 - if (std.mem.eql(u8, base, "repo") or std.mem.eql(u8, base, "repo:") or std.mem.eql(u8, base, "repo:*")) return true; 614 - if (!std.mem.startsWith(u8, base, "repo:")) return false; 615 - const allowed = base["repo:".len..]; 616 - if (std.mem.eql(u8, allowed, collection)) return true; 617 - if (std.mem.endsWith(u8, allowed, ".*")) { 618 - const prefix = allowed[0 .. allowed.len - 2]; 619 - return std.mem.startsWith(u8, collection, prefix) and collection.len > prefix.len and collection[prefix.len] == '.'; 620 - } 621 - return false; 622 - } 623 - 624 - fn repoActionName(action: RepoAction) []const u8 { 625 - return switch (action) { 626 - .create => "create", 627 - .update => "update", 628 - .delete => "delete", 629 - }; 630 - } 631 - 632 - test "repo scope permissions enforce collection and action" { 633 - try std.testing.expect(repoScopeAllows("transition:generic", .create, "earth.cirrus.check.othertestrecord")); 634 - try std.testing.expect(repoScopeAllows("repo:*?action=create", .create, "earth.cirrus.check.othertestrecord")); 635 - try std.testing.expect(!repoScopeAllows("repo:*?action=create", .update, "earth.cirrus.check.othertestrecord")); 636 - try std.testing.expect(repoScopeAllows("repo:earth.cirrus.check.testrecord?action=create", .create, "earth.cirrus.check.testrecord")); 637 - try std.testing.expect(!repoScopeAllows("repo:earth.cirrus.check.testrecord?action=create", .create, "earth.cirrus.check.othertestrecord")); 638 - try std.testing.expect(repoScopeAllows("repo:earth.cirrus.check.*?action=delete", .delete, "earth.cirrus.check.othertestrecord")); 639 - } 640 - 641 - test "blob scope permissions enforce mime type" { 642 - try std.testing.expect(blobScopeAllows("transition:generic", "image/png")); 643 - try std.testing.expect(blobScopeAllows("blob:*/*", "image/png")); 644 - try std.testing.expect(blobScopeAllows("blob:image/*", "image/jpeg")); 645 - try std.testing.expect(blobScopeAllows("blob?accept=image/*&accept=video/*", "video/mp4")); 646 - try std.testing.expect(!blobScopeAllows("blob?accept=image/*", "text/plain")); 647 538 } 648 539 649 540 fn requireRepoMatches(request: *http.Server.Request, account: auth.Account, value: std.json.Value) !void {
+40 -7
src/atproto/server.zig
··· 6 6 const plc = @import("plc.zig"); 7 7 const sync = @import("sync.zig"); 8 8 const http_api = @import("../http/api.zig"); 9 + const scopes = @import("../internal/scopes.zig"); 9 10 const store = @import("../storage/store.zig"); 10 11 const zat = @import("zat"); 11 12 ··· 689 690 defer arena.deinit(); 690 691 const allocator = arena.allocator(); 691 692 692 - const account = http_api.requireBearerAccount(request, allocator) catch |err| switch (err) { 693 + const auth_ctx = http_api.requireBearerAccess(request, allocator) catch |err| switch (err) { 693 694 error.AuthRequired => return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), 694 695 error.InvalidToken => return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), 695 696 }; 697 + const account = auth_ctx.account; 696 698 697 699 var aud_buf: [256]u8 = undefined; 698 700 const audience = http_api.queryParam(request.head.target, "aud", &aud_buf) orelse { 699 701 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing aud"); 700 702 }; 701 - if (zat.Did.parse(audience) == null) { 703 + if (!validServiceAuthAudience(audience)) { 702 704 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid aud"); 703 705 } 704 706 ··· 711 713 if (serviceAuthProtectedMethod(method)) { 712 714 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Cannot request service auth for protected method"); 713 715 } 716 + if (!scopes.rpcAllows(auth_ctx.oauth_scope, audience, method)) { 717 + return http_api.xrpcError(request, .forbidden, "InsufficientScope", "Insufficient scope"); 718 + } 719 + } else if (auth_ctx.oauth_scope != null and !scopes.hasFullAccess(auth_ctx.oauth_scope)) { 720 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "OAuth tokens with granular scopes must specify an lxm parameter"); 714 721 } 715 722 716 723 const requested_exp = try requestedServiceAuthExpiration(request, lxm != null); ··· 721 728 return http_api.json(request, .ok, body_out); 722 729 } 723 730 731 + fn validServiceAuthAudience(audience: []const u8) bool { 732 + if (zat.Did.parse(audience) != null) return true; 733 + const hash = std.mem.indexOfScalar(u8, audience, '#') orelse return false; 734 + if (hash == 0 or hash + 1 >= audience.len) return false; 735 + if (std.mem.indexOfScalarPos(u8, audience, hash + 1, '#') != null) return false; 736 + return zat.Did.parse(audience[0..hash]) != null; 737 + } 738 + 724 739 fn requestedServiceAuthExpiration(request: *http.Server.Request, has_lxm: bool) !?i64 { 725 740 var exp_buf: [32]u8 = undefined; 726 741 const raw = http_api.queryParam(request.head.target, "exp", &exp_buf) orelse return null; ··· 782 797 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 783 798 defer arena.deinit(); 784 799 const allocator = arena.allocator(); 785 - const account = requireAccount(request, allocator) catch return; 800 + const auth_ctx = requireAccountAccess(request, allocator) catch return; 801 + try requireAccountScope(request, auth_ctx.oauth_scope, .email, .manage); 802 + const account = auth_ctx.account; 786 803 const info = store.getEmailInfo(allocator, account.did) orelse { 787 804 return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 788 805 }; ··· 800 817 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 801 818 defer arena.deinit(); 802 819 const allocator = arena.allocator(); 803 - const account = requireAccount(request, allocator) catch return; 820 + const auth_ctx = requireAccountAccess(request, allocator) catch return; 821 + try requireAccountScope(request, auth_ctx.oauth_scope, .email, .manage); 822 + const account = auth_ctx.account; 804 823 var body_buf: [4096]u8 = undefined; 805 824 const body = try http_api.readBody(request, &body_buf); 806 825 const parsed = try http_api.parseJsonBody(request, allocator, body); ··· 828 847 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 829 848 defer arena.deinit(); 830 849 const allocator = arena.allocator(); 831 - const account = requireAccount(request, allocator) catch return; 850 + const auth_ctx = requireAccountAccess(request, allocator) catch return; 851 + try requireAccountScope(request, auth_ctx.oauth_scope, .email, .manage); 852 + const account = auth_ctx.account; 832 853 const info = store.getEmailInfo(allocator, account.did) orelse { 833 854 return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 834 855 }; ··· 846 867 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 847 868 defer arena.deinit(); 848 869 const allocator = arena.allocator(); 849 - const account = requireAccount(request, allocator) catch return; 870 + const auth_ctx = requireAccountAccess(request, allocator) catch return; 871 + try requireAccountScope(request, auth_ctx.oauth_scope, .email, .manage); 872 + const account = auth_ctx.account; 850 873 var body_buf: [4096]u8 = undefined; 851 874 const body = try http_api.readBody(request, &body_buf); 852 875 const parsed = try http_api.parseJsonBody(request, allocator, body); ··· 883 906 } 884 907 885 908 fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.Account { 886 - return http_api.requireBearerAccount(request, allocator) catch |err| { 909 + return (try requireAccountAccess(request, allocator)).account; 910 + } 911 + 912 + fn requireAccountAccess(request: *http.Server.Request, allocator: std.mem.Allocator) !http_api.BearerAccount { 913 + return http_api.requireBearerAccess(request, allocator) catch |err| { 887 914 switch (err) { 888 915 error.AuthRequired => try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), 889 916 error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), 890 917 } 891 918 return error.HandledResponse; 892 919 }; 920 + } 921 + 922 + fn requireAccountScope(request: *http.Server.Request, maybe_scope: ?[]const u8, attr: scopes.AccountAttr, action: scopes.AccountAction) !void { 923 + if (scopes.accountAllows(maybe_scope, attr, action)) return; 924 + try http_api.xrpcError(request, .forbidden, "InsufficientScope", "Insufficient scope"); 925 + return error.HandledResponse; 893 926 } 894 927 895 928 fn requireAdminToken(request: *http.Server.Request) !void {
+5
src/atproto/sync.zig
··· 27 27 const blob = store.getBlob(allocator, did, cid) orelse { 28 28 return http_api.xrpcError(request, .not_found, "BlobNotFound", "Blob not found"); 29 29 }; 30 + const content_length = try std.fmt.allocPrint(allocator, "{d}", .{blob.data.len}); 31 + const disposition = try std.fmt.allocPrint(allocator, "attachment; filename=\"{s}\"", .{cid}); 30 32 const headers = [_]http.Header{ 31 33 .{ .name = "content-type", .value = blob.mime_type }, 34 + .{ .name = "content-length", .value = content_length }, 32 35 .{ .name = "cache-control", .value = "public, max-age=31536000, immutable" }, 33 36 .{ .name = "x-content-type-options", .value = "nosniff" }, 37 + .{ .name = "content-disposition", .value = disposition }, 38 + .{ .name = "content-security-policy", .value = "default-src 'none'; sandbox" }, 34 39 .{ .name = "access-control-allow-origin", .value = "*" }, 35 40 .{ .name = "access-control-allow-private-network", .value = "true" }, 36 41 .{ .name = "connection", .value = "close" },
+262
src/internal/scopes.zig
··· 1 + const std = @import("std"); 2 + 3 + pub const RepoAction = enum { create, update, delete }; 4 + pub const AccountAction = enum { read, manage }; 5 + pub const AccountAttr = enum { email, repo, status, wildcard }; 6 + pub const IdentityAttr = enum { handle, wildcard }; 7 + 8 + pub fn oauthNeedsGranularCheck(maybe_scope: ?[]const u8) bool { 9 + const scope_text = maybe_scope orelse return false; 10 + return !std.mem.eql(u8, scope_text, "com.atproto.access") and 11 + !std.mem.eql(u8, scope_text, "atproto"); 12 + } 13 + 14 + pub fn repoAllows(maybe_scope: ?[]const u8, action: RepoAction, collection: []const u8) bool { 15 + if (!oauthNeedsGranularCheck(maybe_scope)) return true; 16 + var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); 17 + while (scopes.next()) |scope| { 18 + if (scope.len == 0) continue; 19 + if (std.mem.eql(u8, scope, "transition:generic")) return true; 20 + if (!std.mem.startsWith(u8, scope, "repo")) continue; 21 + if (repoScopeMatches(scope, action, collection)) return true; 22 + } 23 + return false; 24 + } 25 + 26 + pub fn blobAllows(maybe_scope: ?[]const u8, mime_type: []const u8) bool { 27 + if (!oauthNeedsGranularCheck(maybe_scope)) return true; 28 + var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); 29 + while (scopes.next()) |scope| { 30 + if (scope.len == 0) continue; 31 + if (std.mem.eql(u8, scope, "transition:generic")) return true; 32 + if (!std.mem.startsWith(u8, scope, "blob")) continue; 33 + if (blobScopeMatches(scope, mime_type)) return true; 34 + } 35 + return false; 36 + } 37 + 38 + pub fn rpcAllows(maybe_scope: ?[]const u8, aud: []const u8, lxm: []const u8) bool { 39 + if (!oauthNeedsGranularCheck(maybe_scope)) return true; 40 + var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); 41 + while (scopes.next()) |scope| { 42 + if (scope.len == 0) continue; 43 + if (std.mem.eql(u8, scope, "transition:generic")) return true; 44 + if (!std.mem.startsWith(u8, scope, "rpc:")) continue; 45 + if (rpcScopeMatches(scope, aud, lxm)) return true; 46 + } 47 + return false; 48 + } 49 + 50 + pub fn accountAllows(maybe_scope: ?[]const u8, attr: AccountAttr, action: AccountAction) bool { 51 + if (!oauthNeedsGranularCheck(maybe_scope)) return true; 52 + var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); 53 + while (scopes.next()) |scope| { 54 + if (scope.len == 0) continue; 55 + if (std.mem.eql(u8, scope, "transition:generic")) return true; 56 + if (!std.mem.startsWith(u8, scope, "account")) continue; 57 + if (accountScopeMatches(scope, attr, action)) return true; 58 + } 59 + return false; 60 + } 61 + 62 + pub fn identityAllows(maybe_scope: ?[]const u8, attr: IdentityAttr) bool { 63 + if (!oauthNeedsGranularCheck(maybe_scope)) return true; 64 + var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); 65 + while (scopes.next()) |scope| { 66 + if (scope.len == 0) continue; 67 + if (std.mem.eql(u8, scope, "transition:generic")) return true; 68 + if (!std.mem.startsWith(u8, scope, "identity")) continue; 69 + if (identityScopeMatches(scope, attr)) return true; 70 + } 71 + return false; 72 + } 73 + 74 + pub fn hasFullAccess(maybe_scope: ?[]const u8) bool { 75 + if (!oauthNeedsGranularCheck(maybe_scope)) return true; 76 + var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); 77 + while (scopes.next()) |scope| { 78 + if (std.mem.eql(u8, scope, "atproto") or std.mem.eql(u8, scope, "transition:generic")) return true; 79 + } 80 + return false; 81 + } 82 + 83 + fn accountScopeMatches(scope: []const u8, attr: AccountAttr, action: AccountAction) bool { 84 + const query_start = std.mem.indexOfScalar(u8, scope, '?'); 85 + const base = if (query_start) |idx| scope[0..idx] else scope; 86 + if (!accountAttrMatches(base, attr)) return false; 87 + if (query_start == null) return true; 88 + 89 + var saw_action = false; 90 + var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); 91 + while (params.next()) |param| { 92 + if (!std.mem.startsWith(u8, param, "action=")) continue; 93 + saw_action = true; 94 + if (std.mem.eql(u8, param["action=".len..], accountActionName(action))) return true; 95 + } 96 + return !saw_action; 97 + } 98 + 99 + fn accountAttrMatches(base: []const u8, attr: AccountAttr) bool { 100 + if (std.mem.eql(u8, base, "account") or std.mem.eql(u8, base, "account:") or std.mem.eql(u8, base, "account:*")) return true; 101 + if (!std.mem.startsWith(u8, base, "account:")) return false; 102 + const allowed = base["account:".len..]; 103 + if (attr == .wildcard) return std.mem.eql(u8, allowed, "*"); 104 + return std.mem.eql(u8, allowed, accountAttrName(attr)); 105 + } 106 + 107 + fn accountActionName(action: AccountAction) []const u8 { 108 + return switch (action) { 109 + .read => "read", 110 + .manage => "manage", 111 + }; 112 + } 113 + 114 + fn accountAttrName(attr: AccountAttr) []const u8 { 115 + return switch (attr) { 116 + .email => "email", 117 + .repo => "repo", 118 + .status => "status", 119 + .wildcard => "*", 120 + }; 121 + } 122 + 123 + fn identityScopeMatches(scope: []const u8, attr: IdentityAttr) bool { 124 + const query_start = std.mem.indexOfScalar(u8, scope, '?'); 125 + const base = if (query_start) |idx| scope[0..idx] else scope; 126 + if (std.mem.eql(u8, base, "identity") or std.mem.eql(u8, base, "identity:") or std.mem.eql(u8, base, "identity:*")) return true; 127 + if (!std.mem.startsWith(u8, base, "identity:")) return false; 128 + const allowed = base["identity:".len..]; 129 + return switch (attr) { 130 + .handle => std.mem.eql(u8, allowed, "handle"), 131 + .wildcard => false, 132 + }; 133 + } 134 + 135 + fn repoScopeMatches(scope: []const u8, action: RepoAction, collection: []const u8) bool { 136 + const query_start = std.mem.indexOfScalar(u8, scope, '?'); 137 + const base = if (query_start) |idx| scope[0..idx] else scope; 138 + if (!repoScopeCollectionMatches(base, collection)) return false; 139 + if (query_start == null) return true; 140 + 141 + var saw_action = false; 142 + var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); 143 + while (params.next()) |param| { 144 + if (!std.mem.startsWith(u8, param, "action=")) continue; 145 + saw_action = true; 146 + if (std.mem.eql(u8, param["action=".len..], repoActionName(action))) return true; 147 + } 148 + return !saw_action; 149 + } 150 + 151 + fn repoScopeCollectionMatches(base: []const u8, collection: []const u8) bool { 152 + if (std.mem.eql(u8, base, "repo") or std.mem.eql(u8, base, "repo:") or std.mem.eql(u8, base, "repo:*")) return true; 153 + if (!std.mem.startsWith(u8, base, "repo:")) return false; 154 + const allowed = base["repo:".len..]; 155 + if (std.mem.eql(u8, allowed, collection)) return true; 156 + if (std.mem.endsWith(u8, allowed, ".*")) { 157 + const prefix = allowed[0 .. allowed.len - 2]; 158 + return std.mem.startsWith(u8, collection, prefix) and collection.len > prefix.len and collection[prefix.len] == '.'; 159 + } 160 + return false; 161 + } 162 + 163 + fn repoActionName(action: RepoAction) []const u8 { 164 + return switch (action) { 165 + .create => "create", 166 + .update => "update", 167 + .delete => "delete", 168 + }; 169 + } 170 + 171 + fn blobScopeMatches(scope: []const u8, mime_type: []const u8) bool { 172 + const query_start = std.mem.indexOfScalar(u8, scope, '?'); 173 + const base = if (query_start) |idx| scope[0..idx] else scope; 174 + if (blobMimeMatches(blobScopePattern(base), mime_type)) return true; 175 + if (query_start == null) return false; 176 + 177 + var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); 178 + while (params.next()) |param| { 179 + if (!std.mem.startsWith(u8, param, "accept=")) continue; 180 + if (blobMimeMatches(param["accept=".len..], mime_type)) return true; 181 + } 182 + return false; 183 + } 184 + 185 + fn blobScopePattern(base: []const u8) []const u8 { 186 + if (std.mem.eql(u8, base, "blob") or std.mem.eql(u8, base, "blob:")) return "*/*"; 187 + if (!std.mem.startsWith(u8, base, "blob:")) return ""; 188 + return base["blob:".len..]; 189 + } 190 + 191 + fn blobMimeMatches(pattern: []const u8, mime_type: []const u8) bool { 192 + if (pattern.len == 0) return false; 193 + if (std.mem.eql(u8, pattern, "*/*")) return true; 194 + if (std.mem.eql(u8, pattern, mime_type)) return true; 195 + if (std.mem.endsWith(u8, pattern, "/*")) { 196 + const prefix = pattern[0 .. pattern.len - 2]; 197 + return std.mem.startsWith(u8, mime_type, prefix) and mime_type.len > prefix.len and mime_type[prefix.len] == '/'; 198 + } 199 + return false; 200 + } 201 + 202 + fn rpcScopeMatches(scope: []const u8, aud: []const u8, lxm: []const u8) bool { 203 + const query_start = std.mem.indexOfScalar(u8, scope, '?'); 204 + const base = if (query_start) |idx| scope[0..idx] else scope; 205 + if (!rpcMethodMatches(base, lxm)) return false; 206 + if (query_start == null) return false; 207 + 208 + var saw_aud = false; 209 + var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); 210 + while (params.next()) |param| { 211 + if (!std.mem.startsWith(u8, param, "aud=")) continue; 212 + saw_aud = true; 213 + const allowed = param["aud=".len..]; 214 + if (std.mem.eql(u8, allowed, "*") or std.mem.eql(u8, allowed, aud)) return true; 215 + } 216 + return !saw_aud; 217 + } 218 + 219 + fn rpcMethodMatches(base: []const u8, lxm: []const u8) bool { 220 + if (!std.mem.startsWith(u8, base, "rpc:")) return false; 221 + const allowed = base["rpc:".len..]; 222 + if (std.mem.eql(u8, allowed, "*") or std.mem.eql(u8, allowed, lxm)) return true; 223 + if (std.mem.endsWith(u8, allowed, ".*")) { 224 + const prefix = allowed[0 .. allowed.len - 2]; 225 + return std.mem.startsWith(u8, lxm, prefix) and lxm.len > prefix.len and lxm[prefix.len] == '.'; 226 + } 227 + return false; 228 + } 229 + 230 + test "repo scopes constrain action and collection" { 231 + try std.testing.expect(repoAllows("repo:*?action=create", .create, "app.bsky.feed.post")); 232 + try std.testing.expect(!repoAllows("repo:*?action=create", .delete, "app.bsky.feed.post")); 233 + try std.testing.expect(repoAllows("repo:app.bsky.feed.*", .update, "app.bsky.feed.like")); 234 + try std.testing.expect(!repoAllows("repo:app.bsky.feed.*", .update, "app.bsky.graph.follow")); 235 + } 236 + 237 + test "blob scopes constrain mime types" { 238 + try std.testing.expect(blobAllows("blob:image/*", "image/jpeg")); 239 + try std.testing.expect(!blobAllows("blob:image/*", "video/mp4")); 240 + try std.testing.expect(blobAllows("blob?accept=image/*&accept=video/*", "video/mp4")); 241 + } 242 + 243 + test "rpc scopes constrain method and audience" { 244 + 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")); 245 + 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")); 246 + try std.testing.expect(rpcAllows("rpc:app.bsky.feed.*?aud=*", "did:web:api.bsky.app#bsky_appview", "app.bsky.feed.getFeed")); 247 + 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")); 248 + try std.testing.expect(!rpcAllows("repo:*", "did:web:api.bsky.app#bsky_appview", "app.bsky.feed.getFeed")); 249 + } 250 + 251 + test "account scopes constrain attribute and action" { 252 + try std.testing.expect(accountAllows("account:email?action=manage", .email, .manage)); 253 + try std.testing.expect(!accountAllows("account:email?action=read", .email, .manage)); 254 + try std.testing.expect(!accountAllows("account:repo?action=manage", .email, .manage)); 255 + try std.testing.expect(accountAllows("account:*", .repo, .manage)); 256 + } 257 + 258 + test "identity scopes constrain attribute" { 259 + try std.testing.expect(identityAllows("identity:handle", .handle)); 260 + try std.testing.expect(!identityAllows("identity:handle", .wildcard)); 261 + try std.testing.expect(identityAllows("identity:*", .wildcard)); 262 + }
+1
src/root.zig
··· 32 32 pub const internal = struct { 33 33 pub const cli = @import("internal/cli.zig"); 34 34 pub const passkeys = @import("internal/passkeys.zig"); 35 + pub const scopes = @import("internal/scopes.zig"); 35 36 pub const sharded_locks = @import("internal/sharded_locks.zig"); 36 37 }; 37 38