Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions examples/phoenix_app/lib/phoenix_app_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ defmodule PhoenixAppWeb.Router do
oauth: [
# client_id: "e2195a7487322a0f19bf"
client_id: "Iv1.d7c611e5607d77b0"
]
],
csp_nonce_assign_key: %{script: :script_src_nonce, style: :style_src_nonce}
]

@oauth_redirect_config [
csp_nonce_assign_key: %{script: :script_src_nonce}
]

def swagger_ui_config, do: @swagger_ui_config
Expand All @@ -28,7 +33,7 @@ defmodule PhoenixAppWeb.Router do

get "/swaggerui", OpenApiSpex.Plug.SwaggerUI, @swagger_ui_config

get "/swaggerui/oauth2-redirect.html", OpenApiSpex.Plug.SwaggerUIOAuth2Redirect, :show
get "/swaggerui/oauth2-redirect.html", OpenApiSpex.Plug.SwaggerUIOAuth2Redirect, @oauth_redirect_config
end

scope "/api" do
Expand Down
42 changes: 36 additions & 6 deletions lib/open_api_spex/plug/swagger_ui.ex
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ defmodule OpenApiSpex.Plug.SwaggerUI do
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.14.0/swagger-ui.css" >
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
<style>
<%= if style_src_nonce do %>
<style nonce="<%= style_src_nonce %>">
<% else %>
<style>
<% end %>
html
{
box-sizing: border-box;
Expand All @@ -68,7 +72,11 @@ defmodule OpenApiSpex.Plug.SwaggerUI do

<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.14.0/swagger-ui-bundle.js" charset="UTF-8"> </script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.14.0/swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
<script>
<%= if script_src_nonce do %>
<script nonce="<%= script_src_nonce %>">
<% else %>
<script>
<% end %>
window.onload = function() {
// Begin Swagger UI call region
const api_spec_url = new URL(window.location);
Expand All @@ -95,7 +103,7 @@ defmodule OpenApiSpex.Plug.SwaggerUI do
}
return request;
}
<%= for {k, v} <- Map.drop(config, [:path, :oauth]) do %>
<%= for {k, v} <- Map.drop(config, [:path, :oauth, :csp_nonce_assign_key]) do %>
, <%= camelize(k) %>: <%= encode_config(camelize(k), v) %>
<% end %>
})
Expand Down Expand Up @@ -135,14 +143,19 @@ defmodule OpenApiSpex.Plug.SwaggerUI do

* `:path` - Required. The URL path to the API definition.
* `:oauth` - Optional. Config to pass to the `SwaggerUIBundle.initOAuth()` function.
* `:csp_nonce_assign_key` - Optional. An assign key to find the CSP nonce value used
for assets. Supports either `atom()` or a map of type
`%{optional(:script) => atom(), optional(:style) => atom()}`. You will probably
want to set this on the `SwaggerUIOAuth2Redirect` plug as well.
* all other opts - forwarded to the `SwaggerUIBundle` constructor

## Example

get "/swaggerui", OpenApiSpex.Plug.SwaggerUI,
path: "/api/openapi",
default_model_expand_depth: 3,
display_operation_id: true
display_operation_id: true,
csp_nonce_assign_key: %{script: :script_src_nonce, style: :style_src_nonce}
"""
@impl Plug
def init(opts) when is_list(opts) do
Expand All @@ -153,7 +166,14 @@ defmodule OpenApiSpex.Plug.SwaggerUI do
def call(conn, config) do
csrf_token = Plug.CSRFProtection.get_csrf_token()
config = supplement_config(config, conn)
html = render(config, csrf_token)

html =
render(
config,
csrf_token,
get_nonce(conn, config, :style),
get_nonce(conn, config, :script)
)

conn
|> Plug.Conn.put_resp_content_type("text/html")
Expand All @@ -164,7 +184,9 @@ defmodule OpenApiSpex.Plug.SwaggerUI do

EEx.function_from_string(:defp, :render, @html, [
:config,
:csrf_token
:csrf_token,
:style_src_nonce,
:script_src_nonce
])

defp camelize(identifier) do
Expand Down Expand Up @@ -203,4 +225,12 @@ defmodule OpenApiSpex.Plug.SwaggerUI do
defp supplement_config(config, _conn) do
config
end

def get_nonce(conn, config, type) do
case config[:csp_nonce_assign_key] do
key when is_atom(key) -> conn.assigns[key]
%{^type => key} when is_atom(key) -> conn.assigns[key]
_ -> nil
end
end
end
156 changes: 87 additions & 69 deletions lib/open_api_spex/plug/swagger_ui_oauth2_redirect.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,87 +12,105 @@ defmodule OpenApiSpex.Plug.SwaggerUIOAuth2Redirect do
<html lang="en-US">
<head>
<title>Swagger UI: OAuth2 Redirect</title>
</head>
<body onload="run()">
<script>
'use strict';
function run() {
var oauth2 = window.opener.swaggerUIRedirectOauth2;
var sentState = oauth2.state;
var redirectUrl = oauth2.redirectUrl;
var isValid, qp, arr;

if (/code|token|error/.test(window.location.hash)) {
qp = window.location.hash.substring(1);
} else {
qp = location.search.substring(1);
}

arr = qp.split("&")
arr.forEach(function (v, i, _arr) { _arr[i] = '"' + v.replace('=', '":"') + '"'; })
qp = qp ? JSON.parse('{' + arr.join() + '}',
function (key, value) {
return key === "" ? value : decodeURIComponent(value)
}
) : {}

isValid = qp.state === sentState
var flow = oauth2.auth.schema.get("flow");

if ((flow === "accessCode" || flow === "authorizationCode") && !oauth2.auth.code) {
if (!isValid) {
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "warning",
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
});
}

if (qp.code) {
delete oauth2.state;
oauth2.auth.code = qp.code;
var callbackOpts1 = { auth: oauth2.auth, redirectUrl: redirectUrl };
oauth2.callback({ auth: oauth2.auth, redirectUrl: redirectUrl });
} else {
let oauthErrorMsg
if (qp.error) {
oauthErrorMsg = "[" + qp.error + "]: " +
(qp.error_description ? qp.error_description + ". " : "no accessCode received from the server. ") +
(qp.error_uri ? "More info: " + qp.error_uri : "");
}

oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "error",
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
});
}
} else {
// oauth2.auth.state = oauth2.state;
var callbackOpts2 = { auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl };
oauth2.callback(callbackOpts2);
}
window.close();
}
<%= if script_src_nonce do %>
<script nonce="<%= script_src_nonce %>">
<% else %>
<script>
<% end %>
'use strict';
(function run() {
var oauth2 = window.opener.swaggerUIRedirectOauth2;
var sentState = oauth2.state;
var redirectUrl = oauth2.redirectUrl;
var isValid, qp, arr;

if (/code|token|error/.test(window.location.hash)) {
qp = window.location.hash.substring(1);
} else {
qp = location.search.substring(1);
}

arr = qp.split("&")
arr.forEach(function (v, i, _arr) { _arr[i] = '"' + v.replace('=', '":"') + '"'; })
qp = qp ? JSON.parse('{' + arr.join() + '}',
function (key, value) {
return key === "" ? value : decodeURIComponent(value)
}
) : {}

isValid = qp.state === sentState
var flow = oauth2.auth.schema.get("flow");

if ((flow === "accessCode" || flow === "authorizationCode") && !oauth2.auth.code) {
if (!isValid) {
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "warning",
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
});
}

if (qp.code) {
delete oauth2.state;
oauth2.auth.code = qp.code;
var callbackOpts1 = { auth: oauth2.auth, redirectUrl: redirectUrl };
oauth2.callback({ auth: oauth2.auth, redirectUrl: redirectUrl });
} else {
let oauthErrorMsg
if (qp.error) {
oauthErrorMsg = "[" + qp.error + "]: " +
(qp.error_description ? qp.error_description + ". " : "no accessCode received from the server. ") +
(qp.error_uri ? "More info: " + qp.error_uri : "");
}

oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "error",
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
});
}
} else {
// oauth2.auth.state = oauth2.state;
var callbackOpts2 = { auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl };
oauth2.callback(callbackOpts2);
}
window.close();
})();
</script>
</body>
</head>
</html>
"""

@doc """
Initializes the plug.

## Options

* `:csp_nonce_assign_key` - Optional. An assign key to find the CSP nonce value used
for assets. Supports either `atom()` or a map of type `%{optional(:script) => atom()}`.

## Example

get "/oauth2-redirect.html",
OpenApiSpex.Plug.SwaggerUIOAuth2Redirect,
csp_nonce_assign_key: %{script: :script_src_nonce}
"""
@impl Plug
def init(_opts), do: []
def init(opts) when is_list(opts) do
Map.new(opts)
end

@impl Plug
def call(conn, _opts) do
html = render()
def call(conn, config) do
html = render(OpenApiSpex.Plug.SwaggerUI.get_nonce(conn, config, :script))

conn
|> put_resp_content_type("text/html")
|> send_resp(200, html)
end

require EEx
EEx.function_from_string(:defp, :render, @html, [])
EEx.function_from_string(:defp, :render, @html, [:script_src_nonce])
end
33 changes: 33 additions & 0 deletions test/plug/swagger_ui_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,37 @@ defmodule OpenApiSpec.Plug.SwaggerUITest do
assert conn.resp_body =~ ~r[pathname.+?/ui]
assert String.contains?(conn.resp_body, token)
end

describe "nonces" do
test "omits nonces if not configured" do
conn = Plug.Test.conn(:get, "/ui") |> SwaggerUI.call(@opts)
refute String.contains?(conn.resp_body, "nonce")
end

test "renders with single key" do
conn =
Plug.Test.conn(:get, "/ui")
|> Plug.Conn.assign(:nonce, "my_nonce")
|> SwaggerUI.call(Map.put(@opts, :csp_nonce_assign_key, :nonce))

assert String.match?(conn.resp_body, ~r/<style.*nonce="my_nonce"/)
assert String.match?(conn.resp_body, ~r/<script.*nonce="my_nonce"/)
end

test "renders with separate keys" do
conn =
Plug.Test.conn(:get, "/ui")
|> Plug.Conn.assign(:style_src_nonce, "my_style_nonce")
|> Plug.Conn.assign(:script_src_nonce, "my_script_nonce")
|> SwaggerUI.call(
Map.put(@opts, :csp_nonce_assign_key, %{
script: :script_src_nonce,
style: :style_src_nonce
})
)

assert String.match?(conn.resp_body, ~r/<style.*nonce="my_style_nonce"/)
assert String.match?(conn.resp_body, ~r/<script.*nonce="my_script_nonce"/)
end
end
end