Skip to content

Commit

Permalink
better security for link deletion + responsive link show
Browse files Browse the repository at this point in the history
  • Loading branch information
glendc committed Nov 1, 2023
1 parent cd0358f commit 01c4a8e
Show file tree
Hide file tree
Showing 3 changed files with 156 additions and 140 deletions.
227 changes: 128 additions & 99 deletions src/router/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ pub struct PostOkTemplate {

#[derive(Deserialize)]
pub struct PostParams {
long: Option<String>,
short: Option<String>,
value: String,
action: String,
}

pub async fn post(
Expand All @@ -81,113 +81,122 @@ pub async fn post(
) -> impl IntoResponse {
if let Some(cookie) = cookies.get(crate::services::COOKIE_NAME) {
if let Some(identity) = state.auth.verify_cookie(cookie.value()) {
if let Some(short) = params.short {
return LinkPostResponse::Other(match state.storage.delete_shortlink(&short, identity.email_hash()).await {
Ok(_) => {
crate::router::shared::InfoTemplate {
title: "Shortlink Deleted".to_string(),
message: format!("The shortlink '{}' has been deleted.", short),
back_path: "/link".to_string(),
}.into_response()
}
Err(err) => {
crate::router::shared::ErrorTemplate {
title: "Failed to Delete Shortlink".to_string(),
message: format!("The shortlink '{}' could not be deleted. {}. Please try again later.", short, err),
back_path: "/link".to_string(),
}.into_response()
return match params.action.as_str() {
"create" => {
let long = params.value;
if long.is_empty() {
return LinkPostResponse::BadRequest {
reason: "URL is not specified.",
long,
};
}
});
}

let long = params.long.unwrap_or_default();
if long.is_empty() {
return LinkPostResponse::BadRequest {
reason: "URL is not specified.",
long,
};
}

// default to https
let long: String = if long.contains("://") {
long.clone()
} else {
format!("https://{}", long)
};
// default to https
let long: String = if long.contains("://") {
long.clone()
} else {
format!("https://{}", long)
};

// validate url
let url = match reqwest::Url::parse(&long) {
Ok(url) => url,
Err(_) => {
return LinkPostResponse::BadRequest {
reason: "URL is invalid.",
long,
// validate url
let url = match reqwest::Url::parse(&long) {
Ok(url) => url,
Err(_) => {
return LinkPostResponse::BadRequest {
reason: "URL is invalid.",
long,
};
}
};
}
};

// only allow http and https
if url.scheme() != "https" && url.scheme() != "http" {
return LinkPostResponse::BadRequest {
reason: "Schema (protocol) is not supported.",
long,
};
}
// only allow http and https
if url.scheme() != "https" && url.scheme() != "http" {
return LinkPostResponse::BadRequest {
reason: "Schema (protocol) is not supported.",
long,
};
}

// validate domains
let domain = match url.domain() {
Some(domain) => domain,
None => {
return LinkPostResponse::BadRequest {
reason: "No domain found.",
long,
// validate domains
let domain = match url.domain() {
Some(domain) => domain,
None => {
return LinkPostResponse::BadRequest {
reason: "No domain found.",
long,
};
}
};
}
};
// ...only allow second level domains or higher
if domain.split('.').count() == 1 {
return LinkPostResponse::BadRequest {
reason: "Bare top level domains are not allowed.",
long,
};
}
// ...only allow domains that are not blocked
if state.storage.is_domain_blocked(domain).await {
return LinkPostResponse::BadRequest {
reason: "The domain is blocked.",
long,
};
}
// ...only allow second level domains or higher
if domain.split('.').count() == 1 {
return LinkPostResponse::BadRequest {
reason: "Bare top level domains are not allowed.",
long,
};
}
// ...only allow domains that are not blocked
if state.storage.is_domain_blocked(domain).await {
return LinkPostResponse::BadRequest {
reason: "The domain is blocked.",
long,
};
}

// create shortlink
let shortlink = Shortlink::new(url.to_string(), identity.email_hash().to_owned());
// create shortlink
let shortlink =
Shortlink::new(url.to_string(), identity.email_hash().to_owned());

// store shortlink
if let Err(err) = state.storage.add_shortlink(&shortlink).await {
tracing::error!(
"Failed to store shortlink for long url {} by {}: {}",
shortlink.owner_email(),
shortlink.link_long(),
err
);
return LinkPostResponse::Exception {
reason: "Failed to store shortlink",
long,
};
};
// store shortlink
if let Err(err) = state.storage.add_shortlink(&shortlink).await {
tracing::error!(
"Failed to store shortlink for long url {} by {}: {}",
shortlink.owner_email(),
shortlink.link_long(),
err
);
return LinkPostResponse::Exception {
reason: "Failed to store shortlink",
long,
};
};

return LinkPostResponse::Ok {
email: identity.email().to_owned(),
long: shortlink.link_long().to_string(),
short: shortlink.link_short(
if host.to_lowercase().contains("bckt.xyz") {
"https"
} else {
"http"
},
&host,
),
hash: shortlink.link_hash().to_owned(),
return LinkPostResponse::Ok {
email: identity.email().to_owned(),
long: shortlink.link_long().to_string(),
short: shortlink.link_short(
if host.to_lowercase().contains("bckt.xyz") {
"https"
} else {
"http"
},
&host,
),
hash: shortlink.link_hash().to_owned(),
};
}
"delete" => {
let short = params.value;
if short.is_empty() {
return LinkPostResponse::ShortUrlMissing;
}
LinkPostResponse::Other(match state.storage.delete_shortlink(&short, identity.email_hash()).await {
Ok(_) => {
crate::router::shared::InfoTemplate {
title: "Shortlink Deleted".to_string(),
message: format!("The shortlink '{}' has been deleted.", short),
back_path: "/link".to_string(),
}.into_response()
}
Err(err) => {
crate::router::shared::ErrorTemplate {
title: "Failed to Delete Shortlink".to_string(),
message: format!("The shortlink '{}' could not be deleted. {}. Please try again later.", short, err),
back_path: "/link".to_string(),
}.into_response()
}
})
}
other => LinkPostResponse::BadAction(other.to_string()),
};
}
}
Expand All @@ -199,6 +208,8 @@ enum LinkPostResponse {
reason: &'static str,
long: String,
},
ShortUrlMissing,
BadAction(String),
Forbidden,
Exception {
reason: &'static str,
Expand Down Expand Up @@ -229,6 +240,24 @@ impl IntoResponse for LinkPostResponse {
},
)
.into_response(),
LinkPostResponse::ShortUrlMissing => (
StatusCode::BAD_REQUEST,
super::shared::ErrorTemplate {
title: "Short Url Missing".to_string(),
message: "Cannot delete shortlink without short url (hash).".to_string(),
back_path: "/link".to_string(),
},
)
.into_response(),
LinkPostResponse::BadAction(action) => (
StatusCode::BAD_REQUEST,
super::shared::ErrorTemplate {
title: "Bad Action".to_string(),
message: format!("Invalid action {action}."),
back_path: "/link".to_string(),
},
)
.into_response(),
LinkPostResponse::Forbidden => (
StatusCode::FORBIDDEN,
super::shared::ErrorTemplate {
Expand Down
9 changes: 7 additions & 2 deletions src/services/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,17 @@ impl Storage {
}

pub async fn delete_shortlink(&self, id: &str, owner_email: &str) -> Result<(), String> {
sqlx::query("DELETE FROM bckt_links WHERE owner_email = $1 AND link_hash = $2")
let len = sqlx::query("DELETE FROM bckt_links WHERE owner_email = $1 AND link_hash = $2")
.bind(owner_email)
.bind(id)
.execute(&self.pool)
.await
.map_err(|e| e.to_string())
.map(|_| ())
.map(|result| result.rows_affected())?;
if len == 0 {
Err(format!("no shortlink '{id}' exists for current owner"))
} else {
Ok(())
}
}
}
60 changes: 21 additions & 39 deletions templates/content/link.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@
<form action="/link" method="post" style="margin: 10px 0 0 0; padding: 10px;">
<div class="table rows">
<p>
<label for="long">long link:</label>
<label for="value">long link:</label>
</p>
<input type="hidden" name="action" value="create">
<p>
<input id="long" name="long" type="long" placeholder="long"
<input id="value" name="value" type="text" placeholder="long"
style="width: 100%; margin: auto; vertical-align: middle;" {% match long %} {% when Some with (val)
%} value="{{ val }}" {% when None %} {% endmatch %} autofocus>
</p>
Expand All @@ -32,43 +32,25 @@
{% if shortlinks.len() > 0 %}
<div>
<h3>Previously created shortlinks:</h3>
<table>
<thead>
<tr>
<th>short</th>
<th>long</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{% for shortlink in shortlinks %}
<tr>
{% let short = shortlink.link_short(scheme, host) %}
<td>
<a href="/{{ shortlink.link_hash() }}" hx-boost="false">{{ short }}</a>
</td>
<td>
<a href="{{ shortlink.link_long() }}">{{ shortlink.link_long() }}</a>
</td>
<td>
<button
_="on click navigator.clipboard.writeText('{{ short }}')"
class="big"
>
📋&nbsp;Copy
</button>
</td>
<td>
<form action="/link" method="post">
<input type="hidden" name="short" value="{{ shortlink.link_hash() }}">
<input type="hidden" name="action" value="delete">
<input class="button bad big" type="submit" value="🗑️&nbsp;Delete">
</form>
</tr>
{% endfor %}
</tbody>
</table>
{% for shortlink in shortlinks %}
{% let short = shortlink.link_short(scheme, host) %}
<div class="box f-row">
<div>
<span>🔗 <a href="/{{ shortlink.link_hash() }}" hx-boost="false">{{ short }}</a></span>
<span><a href="{{ shortlink.link_long() }}">{{ shortlink.link_long() }}</a></span>
</div>
<section class="tool-bar">
<button _="on click navigator.clipboard.writeText('{{ short }}')" class="big" title="copy shortlink">
📋
</button>
<form action="/link" method="post">
<input type="hidden" name="value" value="{{ shortlink.link_hash() }}">
<input type="hidden" name="action" value="delete">
<input class="button bad big" type="submit" value="🗑️" title="delete shortlink">
</form>
</section>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}

0 comments on commit 01c4a8e

Please sign in to comment.