csantanapr closed pull request #3388: Update require-whisk-auth behavior to secure web action URL: https://github.com/apache/incubator-openwhisk/pull/3388
This is a PR merged from a forked repository. As GitHub hides the original diff on merge, it is displayed below for the sake of provenance: As this is a foreign pull request (from a fork), the diff is supplied below (as it won't show otherwise due to GitHub magic): diff --git a/common/scala/src/main/scala/whisk/core/entity/WhiskAction.scala b/common/scala/src/main/scala/whisk/core/entity/WhiskAction.scala index 07b9efeb94..97f98ae315 100644 --- a/common/scala/src/main/scala/whisk/core/entity/WhiskAction.scala +++ b/common/scala/src/main/scala/whisk/core/entity/WhiskAction.scala @@ -313,6 +313,9 @@ object WhiskAction extends DocumentFactory[WhiskAction] with WhiskEntityQueries[ override val cacheEnabled = true + val requireWhiskAuthAnnotation = "require-whisk-auth" + val requireWhiskAuthHeader = "x-require-whisk-auth" + // overriden to store attached code override def put[A >: WhiskAction](db: ArtifactStore[A], doc: WhiskAction)( implicit transid: TransactionId, diff --git a/core/controller/src/main/scala/whisk/core/controller/WebActions.scala b/core/controller/src/main/scala/whisk/core/controller/WebActions.scala index 9987a2100b..b0cd6ba231 100644 --- a/core/controller/src/main/scala/whisk/core/controller/WebActions.scala +++ b/core/controller/src/main/scala/whisk/core/controller/WebActions.scala @@ -483,7 +483,14 @@ trait WhiskWebActionsApi extends Directives with ValidateRequestSize with PostAc provide(fullyQualifiedActionName(actionName)) { fullActionName => onComplete(verifyWebAction(fullActionName, onBehalfOf.isDefined)) { case Success((actionOwnerIdentity, action)) => - if (!action.annotations.getAs[Boolean]("web-custom-options").exists(identity)) { + var requiredAuthOk = + requiredWhiskAuthSuccessful(action.annotations, context.headers).getOrElse(true) + if (!requiredAuthOk) { + logging.debug( + this, + "web action with require-whisk-auth was invoked without a matching x-require-whisk-auth header value") + terminate(Unauthorized) + } else if (!action.annotations.getAs[Boolean]("web-custom-options").exists(identity)) { respondWithHeaders(defaultCorsResponse(context.headers)) { if (context.method == OPTIONS) { complete(OK, HttpEntity.Empty) @@ -719,7 +726,8 @@ trait WhiskWebActionsApi extends Directives with ValidateRequestSize with PostAc private def confirmExportedAction(actionLookup: Future[WhiskActionMetaData], authenticated: Boolean)( implicit transid: TransactionId): Future[WhiskActionMetaData] = { actionLookup flatMap { action => - val requiresAuthenticatedUser = action.annotations.getAs[Boolean]("require-whisk-auth").exists(identity) + val requiresAuthenticatedUser = + action.annotations.getAs[Boolean](WhiskAction.requireWhiskAuthAnnotation).exists(identity) val isExported = action.annotations.getAs[Boolean]("web-export").exists(identity) if ((isExported && requiresAuthenticatedUser && authenticated) || @@ -751,4 +759,32 @@ trait WhiskWebActionsApi extends Directives with ValidateRequestSize with PostAc projection.getOrElse(List.empty) } + + /** + * Check if "require-whisk-auth" authentication is needed, and if so, authenticate the request + * NOTE: Only number or string JSON "require-whisk-auth" annotation values are supported + * + * @param annotations - web action annotations + * @param reqHeaders - web action invocation request headers + * @return Option[Boolean] + * None if annotations does not include require-whisk-auth (i.e. auth test not needed) + * Some(true) if annotations includes require-whisk-auth and it's value matches the request header `X-Require-Whisk-Auth` value + * Some(false) if annotations includes require-whisk-auth and the request does not include the header `X-Require-Whisk-Auth` + * Some(false) if annotations includes require-whisk-auth and it's value deos not match the request header `X-Require-Whisk-Auth` value + */ + private def requiredWhiskAuthSuccessful(annotations: Parameters, reqHeaders: Seq[HttpHeader]): Option[Boolean] = { + annotations + .get(WhiskAction.requireWhiskAuthAnnotation) + .flatMap { + case JsString(authStr) => Some(authStr) + case JsNumber(authNum) => Some(authNum.toString) + case _ => None + } + .map { reqWhiskAuthAnnotationStr => + reqHeaders + .find(_.is(WhiskAction.requireWhiskAuthHeader)) + .map(_.value == reqWhiskAuthAnnotationStr) + .getOrElse(false) // false => when no x-require-whisk-auth header is present + } + } } diff --git a/docs/annotations.md b/docs/annotations.md index d92bf3346c..ab31bcae8e 100644 --- a/docs/annotations.md +++ b/docs/annotations.md @@ -48,7 +48,7 @@ and must be present and explicitly set to `true` to have an affect. The annotati * `final`: Makes all of the action parameters that are already defined immutable. A parameter of an action carrying the annotation may not be overridden by invoke-time parameters once the parameter has a value defined through its enclosing package or the action definition. * `raw-http`: When set, the HTTP request query and body parameters are passed to the action as reserved properties. * `web-custom-options`: When set, this annotation enables a web action to respond to OPTIONS requests with customized headers, otherwise a [default CORS response](webactions.md#options-requests) applies. -* `require-whisk-auth`: This annotation protects the web action so that it is only accessible to an authenticated subject. It is important to note that the _owner_ of the web action will still incur the cost of running them in the system (i.e., the _owner_ of the action also owns the activations record). +* `require-whisk-auth`: This annotation protects the web action so that it is only invoked by requests that provide appropriate authentication credentials. When set to a boolean value, it controls whether or not the request's Basic Authentication value (i.e. Whisk auth key) will be authenticated - a value of `true` will authenticate the credentials, a value of `false` will invoke the action without any authentication. When set to a number or a string, this value must match the request's `X-Require-Whisk-Auth` header value. In both cases, it is important to note that the _owner_ of the web action will still incur the cost of running them in the system (i.e., the _owner_ of the action also owns the activations record). # Annotations specific to activations diff --git a/docs/webactions.md b/docs/webactions.md index 2aaa6085b1..6bd1f3159f 100644 --- a/docs/webactions.md +++ b/docs/webactions.md @@ -34,7 +34,7 @@ https://${APIHOST}/api/v1/web/guest/demo/hello ``` Using the `--web` flag with a value of `true` or `yes` allows an action to be accessible via REST interface without the -need for credentials. A web action can be invoked using a URL that is structured as follows: +need for credentials. To configure a web action with credentials refer to [Securing web actions](webactions.md#securing-web-actions). A web action can be invoked using a URL that is structured as follows: `https://{APIHOST}/api/v1/web/{QUALIFIED ACTION NAME}.{EXT}`. The fully qualified name of an action consists of three parts: the namespace, the package name, and the action name. @@ -280,6 +280,20 @@ $ wsk action create /guest/demo/hello hello.js \ The result of these changes is that the `name` is bound to `Jane` and may not be overridden by query or body parameters because of the final annotation. This secures the action against query or body parameters that try to change this value whether by accident or intentionally. +## Securing web actions + +By default, a web action can be invoked by anyone having the web action's invocation URL. Use the `require-whisk-auth` [web action annotation](annotations.md#annotations-specific-to-web-actions) to secure the web action. When the `require-whisk-auth` annotation is set to `true`, the action will authenticate the invocation request's Basic Authorization credentials against the action owner's whisk auth key. When set to a number or a case-sensitive string, the action's invocation request must include a `X-Require-Whisk-Auth` header having this same value. Secured web actions will return a `Not Authorized` when credential validation fails. + +```bash +$ wsk action update /guest/demo/hello hello.js --web true -a require-whisk-auth my-secret +``` + +```bash +$ curl https://${APIHOST}/api/v1/web/guest/demo/hello.json?name=Jane -X GET -H "X-Require-Whisk-Auth: my-secret" +``` + +It's important to note that the owner of the web action owns all of the web action's activations records and will incur the cost of running the action in the system regardless of how the action was invoked. + ## Disabling web actions To disable a web action from being invoked via web API (`https://APIHOST/api/v1/web/`), pass a value of `false` or `no` to the `--web` flag while updating an action with the CLI. diff --git a/tests/src/test/scala/whisk/core/controller/test/WebActionsApiTests.scala b/tests/src/test/scala/whisk/core/controller/test/WebActionsApiTests.scala index 044ab53ccd..36555b92e3 100644 --- a/tests/src/test/scala/whisk/core/controller/test/WebActionsApiTests.scala +++ b/tests/src/test/scala/whisk/core/controller/test/WebActionsApiTests.scala @@ -133,6 +133,8 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac var failThrottleForSubject: Option[Subject] = None // toggle to cause throttle to fail for subject var actionResult: Option[JsObject] = None var requireAuthentication = false // toggle require-whisk-auth annotation on action + var requireAuthenticationAsBoolean = true // toggle value set in require-whisk-auth annotation (true or requireAuthenticationKey) + var requireAuthenticationKey = "example-web-action-api-key" var customOptions = true // toogle web-custom-options annotation on action var invocationCount = 0 var invocationsAllowed = 0 @@ -148,6 +150,7 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac failThrottleForSubject = None actionResult = None requireAuthentication = false + requireAuthenticationAsBoolean = true customOptions = true assert(invocationsAllowed == invocationCount, "allowed invoke count did not match actual") } @@ -197,7 +200,9 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac annotations ++ Parameters("web-export", JsBoolean(true)) ++ { if (requireAuthentication) { - Parameters("require-whisk-auth", JsBoolean(true)) + Parameters( + "require-whisk-auth", + (if (requireAuthenticationAsBoolean) JsBoolean(true) else JsString(requireAuthenticationKey))) } else Parameters() } ++ { if (customOptions) { @@ -209,7 +214,9 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac Parameters("web-export", JsBoolean(true)) ++ Parameters("raw-http", JsBoolean(true)) ++ { if (requireAuthentication) { - Parameters("require-whisk-auth", JsBoolean(true)) + Parameters( + "require-whisk-auth", + (if (requireAuthenticationAsBoolean) JsBoolean(true) else JsString(requireAuthenticationKey))) } else Parameters() } ++ { if (customOptions) { @@ -379,14 +386,17 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac Seq(s"$systemId/proxy/export_auth").foreach { path => allowedMethods.foreach { m => - if (creds.isDefined) - invocationsAllowed += 1 requireAuthentication = true + Seq(true, false).foreach { useReqWhiskAuthBool => + requireAuthenticationAsBoolean = useReqWhiskAuthBool + } - m(s"$testRoutePath/${path}.json") ~> Route.seal(routes(creds)) ~> check { - creds match { - case None => status should be(Unauthorized) - case Some(user) => + if (requireAuthenticationAsBoolean) { + if (creds.isDefined) { + val user = creds.get + invocationsAllowed += 1 + m(s"$testRoutePath/${path}.json") ~> Route + .seal(routes(creds)) ~> check { status should be(OK) val response = responseAs[JsObject] response shouldBe JsObject( @@ -394,6 +404,42 @@ trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEac "action" -> "export_auth".toJson, "content" -> metaPayload(m.method.name.toLowerCase, JsObject(), creds, pkgName = "proxy")) response.fields("content").asJsObject.fields(webApiDirectives.namespace) shouldBe user.namespace.toJson + } + } else { + m(s"$testRoutePath/${path}.json") ~> Route.seal(routes(creds)) ~> check { + status should be(Unauthorized) + } + } + } else if (creds.isDefined) { + val user = creds.get + invocationsAllowed += 1 + + // web action require-whisk-auth is set and the header X-Require-Whisk-Auth value does not matches + m(s"$testRoutePath/${path}.json") ~> addHeader("X-Require-Whisk-Auth", requireAuthenticationKey) ~> Route + .seal(routes(creds)) ~> check { + status should be(OK) + val response = responseAs[JsObject] + response shouldBe JsObject( + "pkg" -> s"$systemId/proxy".toJson, + "action" -> "export_auth".toJson, + "content" -> metaPayload( + m.method.name.toLowerCase, + JsObject(), + creds, + pkgName = "proxy", + headers = List(RawHeader("X-Require-Whisk-Auth", requireAuthenticationKey)))) + response.fields("content").asJsObject.fields(webApiDirectives.namespace) shouldBe user.namespace.toJson + } + + // web action require-whisk-auth is set, but the header X-Require-Whisk-Auth value does not match + m(s"$testRoutePath/${path}.json") ~> addHeader("X-Require-Whisk-Auth", requireAuthenticationKey + "-bad") ~> Route + .seal(routes(creds)) ~> check { + status should be(Unauthorized) + } + } else { + // web action require-whisk-auth is set, but the header X-Require-Whisk-Auth value is not set + m(s"$testRoutePath/${path}.json") ~> Route.seal(routes(creds)) ~> check { + status should be(Unauthorized) } } } ---------------------------------------------------------------- This is an automated message from the Apache Git Service. To respond to the message, please log on GitHub and use the URL above to go to the specific comment. For queries about this service, please contact Infrastructure at: us...@infra.apache.org With regards, Apache Git Services