Skip to content

Commit 22f3500

Browse files
authored
feat(https): add per-function CORS support (#106)
1 parent c9da516 commit 22f3500

File tree

5 files changed

+131
-24
lines changed

5 files changed

+131
-24
lines changed

lib/src/firebase.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ extension FirebaseX on Firebase {
180180
bool external = false,
181181
String? documentPattern,
182182
String? refPattern,
183+
List<String>? allowedOrigins,
183184
}) {
184185
// Check for duplicate function names
185186
if (functions.any((f) => f.name == name)) {
@@ -195,6 +196,7 @@ extension FirebaseX on Firebase {
195196
name: transformedName,
196197
handler: handler,
197198
external: external,
199+
allowedOrigins: allowedOrigins,
198200
documentPattern: documentPattern,
199201
refPattern: refPattern,
200202
),
@@ -214,6 +216,7 @@ final class FirebaseFunctionDeclaration {
214216
required this.name,
215217
required this.handler,
216218
required this.external,
219+
this.allowedOrigins,
217220
this.documentPattern,
218221
this.refPattern,
219222
}) : path = name;
@@ -238,6 +241,9 @@ final class FirebaseFunctionDeclaration {
238241
/// Event-driven functions are internal (false, POST only).
239242
final bool external;
240243

244+
/// Allowed origins for CORS (if specified).
245+
final List<String>? allowedOrigins;
246+
241247
/// The function handler.
242248
final FirebaseFunctionHandler handler;
243249
}

lib/src/https/https_namespace.dart

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,20 @@ class HttpsNamespace extends FunctionsNamespace {
5151
// ignore: experimental_member_use
5252
@mustBeConst HttpsOptions? options = const HttpsOptions(),
5353
}) {
54-
firebase.registerFunction(name, (request) async {
55-
try {
56-
return await handler(request);
57-
} on HttpsError catch (e) {
58-
return e.toShelfResponse();
59-
} catch (e, stackTrace) {
60-
return logInternalError(e, stackTrace).toShelfResponse();
61-
}
62-
}, external: true);
54+
firebase.registerFunction(
55+
name,
56+
(request) async {
57+
try {
58+
return await handler(request);
59+
} on HttpsError catch (e) {
60+
return e.toShelfResponse();
61+
} catch (e, stackTrace) {
62+
return logInternalError(e, stackTrace).toShelfResponse();
63+
}
64+
},
65+
external: true,
66+
allowedOrigins: options?.cors?.runtimeValue(),
67+
);
6368
}
6469

6570
/// Creates an HTTPS callable function (untyped data).
@@ -141,7 +146,7 @@ class HttpsNamespace extends FunctionsNamespace {
141146
(result) => result.data,
142147
(result) => result.toResponse(),
143148
);
144-
});
149+
}, allowedOrigins: options?.cors?.runtimeValue());
145150
}
146151

147152
/// Creates an HTTPS callable function with typed data.
@@ -225,7 +230,7 @@ class HttpsNamespace extends FunctionsNamespace {
225230
headers: {'Content-Type': 'application/json'},
226231
),
227232
);
228-
});
233+
}, allowedOrigins: options?.cors?.runtimeValue());
229234
}
230235

231236
/// Internal handler for callable functions.

lib/src/server.dart

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,38 @@ const _corsAnyOriginHeaders = {
9999
'Access-Control-Allow-Headers': '*',
100100
};
101101

102+
Response _buildOptionsCorsResponse(
103+
Request request,
104+
List<String> allowedOrigins,
105+
) => Response.ok('', headers: corsHeadersFor(request, allowedOrigins));
106+
107+
Response _applyCorsHeaders(
108+
Request request,
109+
Response response,
110+
List<String> allowedOrigins,
111+
) => response.change(headers: corsHeadersFor(request, allowedOrigins));
112+
113+
@visibleForTesting
114+
Map<String, String> corsHeadersFor(
115+
Request request,
116+
List<String> allowedOrigins,
117+
) {
118+
if (allowedOrigins.contains('*')) {
119+
return _corsAnyOriginHeaders;
120+
}
121+
122+
final origin = request.headers['origin'];
123+
if (origin != null && allowedOrigins.contains(origin)) {
124+
return {
125+
'Access-Control-Allow-Origin': origin,
126+
'Access-Control-Allow-Methods': '*',
127+
'Access-Control-Allow-Headers': '*',
128+
};
129+
}
130+
131+
return const {};
132+
}
133+
102134
/// Routes incoming requests to the appropriate function handler.
103135
FutureOr<Response> _routeRequest(
104136
Request request,
@@ -144,7 +176,7 @@ FutureOr<Response> _routeToTargetFunction(
144176
Firebase firebase,
145177
FirebaseEnv env,
146178
String functionTarget,
147-
) {
179+
) async {
148180
final functions = firebase.functions;
149181

150182
// Find the function with matching name
@@ -165,6 +197,11 @@ FutureOr<Response> _routeToTargetFunction(
165197
// from the Node.js model does not apply here.
166198

167199
// Validate HTTP method for event functions
200+
if (request.method.toUpperCase() == 'OPTIONS' &&
201+
targetFunction.allowedOrigins != null) {
202+
return _buildOptionsCorsResponse(request, targetFunction.allowedOrigins!);
203+
}
204+
168205
if (!targetFunction.external && request.method.toUpperCase() != 'POST') {
169206
return Response(
170207
405,
@@ -173,10 +210,12 @@ FutureOr<Response> _routeToTargetFunction(
173210
);
174211
}
175212

176-
// Execute the target function (all requests go to this function)
177-
// Wrap with onInit to ensure initialization callback runs before first execution
178213
final wrappedHandler = withInit(targetFunction.handler);
179-
return wrappedHandler(request);
214+
final response = await wrappedHandler(request);
215+
if (targetFunction.allowedOrigins != null) {
216+
return _applyCorsHeaders(request, response, targetFunction.allowedOrigins!);
217+
}
218+
return response;
180219
}
181220

182221
/// Routes request by path matching (development/shared process mode).
@@ -215,16 +254,29 @@ FutureOr<Response> _routeByPath(
215254

216255
// Try to find a matching function by name
217256
for (final function in functions) {
218-
// Internal functions (events) only accept POST requests
219-
if (!function.external && currentRequest.method.toUpperCase() != 'POST') {
220-
continue;
221-
}
222-
223-
// Match by function name
224257
if (functionName == function.name) {
225-
// Wrap with onInit to ensure initialization callback runs before first execution
258+
if (currentRequest.method.toUpperCase() == 'OPTIONS' &&
259+
function.allowedOrigins != null) {
260+
return _buildOptionsCorsResponse(
261+
currentRequest,
262+
function.allowedOrigins!,
263+
);
264+
}
265+
266+
if (!function.external && currentRequest.method.toUpperCase() != 'POST') {
267+
continue;
268+
}
269+
226270
final wrappedHandler = withInit(function.handler);
227-
return wrappedHandler(currentRequest);
271+
final response = await wrappedHandler(currentRequest);
272+
if (function.allowedOrigins != null) {
273+
return _applyCorsHeaders(
274+
currentRequest,
275+
response,
276+
function.allowedOrigins!,
277+
);
278+
}
279+
return response;
228280
}
229281
}
230282

test/unit/https_namespace_test.dart

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,8 @@ void main() {
385385
(request) async => Response.ok('OK'),
386386
);
387387

388-
expect(_findFunction(firebase, 'options-function'), isNotNull);
388+
final func = _findFunction(firebase, 'options-function')!;
389+
expect(func.allowedOrigins, ['https://example.com']);
389390
});
390391

391392
test('CallableOptions can be provided', () {
@@ -399,6 +400,20 @@ void main() {
399400

400401
expect(_findFunction(firebase, 'callable-options-function'), isNotNull);
401402
});
403+
404+
test('CallableOptions can be provided passing allowedOrigins', () {
405+
https.onCall(
406+
name: 'callableOptionsFunctionWithOrigins',
407+
options: const CallableOptions(cors: Cors(['https://example.com'])),
408+
(request, response) async => CallableResult('OK'),
409+
);
410+
411+
final func = _findFunction(
412+
firebase,
413+
'callable-options-function-with-origins',
414+
)!;
415+
expect(func.allowedOrigins, ['https://example.com']);
416+
});
402417
});
403418
});
404419
}

test/unit/server_test.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// limitations under the License.
1414

1515
import 'package:firebase_functions/src/server.dart';
16+
import 'package:shelf/shelf.dart';
1617
import 'package:test/test.dart';
1718

1819
void main() {
@@ -49,5 +50,33 @@ void main() {
4950
expect(extractTraceId('1234567890xyzdef1234567890abcdef/5'), isNull);
5051
});
5152
});
53+
54+
group('corsHeadersFor', () {
55+
test('returns asterisk when allowedOrigins contains asterisk', () {
56+
final request = Request('GET', Uri.parse('http://localhost/test'));
57+
final headers = corsHeadersFor(request, ['*']);
58+
expect(headers['Access-Control-Allow-Origin'], '*');
59+
});
60+
61+
test('echoes the Origin header if it matches allowedOrigins', () {
62+
final request = Request(
63+
'GET',
64+
Uri.parse('http://localhost/test'),
65+
headers: {'origin': 'https://example.com'},
66+
);
67+
final headers = corsHeadersFor(request, ['https://example.com']);
68+
expect(headers['Access-Control-Allow-Origin'], 'https://example.com');
69+
});
70+
71+
test('returns empty map if no match is found', () {
72+
final request = Request(
73+
'GET',
74+
Uri.parse('http://localhost/test'),
75+
headers: {'origin': 'https://evil.com'},
76+
);
77+
final headers = corsHeadersFor(request, ['https://example.com']);
78+
expect(headers, isEmpty);
79+
});
80+
});
5281
});
5382
}

0 commit comments

Comments
 (0)